Files
ApiServer-Web-admin_dashboa…/src/views/sms/SmsTemplate.vue
T
shiran 4180f73c53
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 36s
feat(admin): 订单管理重构、设置管理增强、短信签名模板管理及通知渠道优化
- 订单列表重构为卡片式布局并新增筛选功能

- 设置管理支持struct/struct_list类型配置

- 新增短信签名和模板独立管理页面

- 通知渠道新增短信渠道配置

- 产品参数管理优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 18:27:23 +08:00

633 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sms-template-page">
<div class="page-header">
<div class="header-info">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
<path d="M4 4h16v16H4z" rx="2"/>
<path d="M8 8h8M8 12h6M8 16h4" stroke-linecap="round"/>
</svg>
</div>
<div>
<h2 class="header-title">短信模板管理</h2>
<p class="header-desc">管理用户短信模板的审核推荐模板的维护</p>
</div>
</div>
</div>
<!-- 服务选择 -->
<div class="filter-bar">
<el-select v-model="serviceId" placeholder="选择主控服务" style="width: 200px" @change="onServiceChange">
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
</div>
<!-- 主内容 Tabs -->
<el-tabs v-model="activeTab" type="border-card">
<!-- 用户模板 -->
<el-tab-pane label="用户模板" name="user">
<div class="tab-toolbar">
<div class="tab-filters">
<el-select v-model="tplQuery.status" placeholder="全部状态" clearable style="width: 130px" @change="fetchTemplates">
<el-option label="草稿" :value="0" />
<el-option label="审核中" :value="1" />
<el-option label="已通过" :value="2" />
<el-option label="已驳回" :value="3" />
</el-select>
<el-input
v-if="tplFilterUserInfo"
:model-value="`${tplFilterUserInfo.user_name} (ID: ${tplQuery.user_id})`"
readonly
style="width: 200px"
>
<template #suffix>
<el-icon class="clear-icon" @click="clearTplFilterUser"><Close /></el-icon>
</template>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-input v-else placeholder="筛选用户" readonly style="width: 140px" @click="tplFilterUserSelectorVisible = true">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
<el-button type="primary" @click="fetchTemplates">搜索</el-button>
</div>
<el-button type="primary" @click="handleAddTemplate">
<el-icon><Plus /></el-icon> 新增模板
</el-button>
</div>
<el-table :data="tplList" v-loading="tplLoading" stripe border style="width: 100%">
<el-table-column prop="ID" label="ID" width="70" align="center" />
<el-table-column label="用户ID" width="90">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: row.user_id } })">{{ row.user_id }}</el-link>
</template>
</el-table-column>
<el-table-column prop="name" label="模板名称" min-width="140" />
<el-table-column label="内容" min-width="200">
<template #default="{ row }">
<el-tooltip :content="row.content" placement="top" :show-after="300">
<span class="content-cell">{{ row.content }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="参数" width="150">
<template #default="{ row }">
<template v-if="row.params && row.params.length">
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="驳回原因" min-width="130">
<template #default="{ row }">
<span v-if="row.reject_reason" class="reject-text">{{ row.reject_reason }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditTemplate(row)">编辑</el-button>
<el-button v-if="row.status === 0" type="warning" link @click="handleSubmitTpl(row)">提交审核</el-button>
<el-button v-if="row.status === 1" type="success" link @click="handleApproveTpl(row)">通过</el-button>
<el-button v-if="row.status === 1" type="danger" link @click="handleRejectTpl(row)">驳回</el-button>
<el-button type="danger" link @click="handleDeleteTpl(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="tplQuery.page"
v-model:page-size="tplQuery.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="tplTotal"
@size-change="fetchTemplates"
@current-change="fetchTemplates"
background
class="pagination"
/>
</el-tab-pane>
<!-- 推荐模板 -->
<el-tab-pane label="推荐模板" name="recommended">
<div class="tab-toolbar">
<div></div>
<el-button type="primary" @click="handleAddRecommended">
<el-icon><Plus /></el-icon> 新增推荐模板
</el-button>
</div>
<el-table :data="recList" v-loading="recLoading" stripe border style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="分类" width="120">
<template #default="{ row }">
<el-tag v-if="row.category" size="small">{{ row.category }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="220">
<template #default="{ row }">
<el-tooltip :content="row.content" placement="top" :show-after="300">
<span class="content-cell">{{ row.content }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="参数" width="150">
<template #default="{ row }">
<template v-if="row.params && row.params.length">
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditRecommended(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteRecommended(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="recQuery.page"
v-model:page-size="recQuery.count"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="recTotal"
@size-change="fetchRecommended"
@current-change="fetchRecommended"
background
class="pagination"
/>
</el-tab-pane>
</el-tabs>
<!-- 用户模板表单对话框 -->
<el-dialog v-model="tplDialogVisible" :title="tplDialogType === 'add' ? '新增模板' : '编辑模板'" width="640px" append-to-body destroy-on-close>
<el-form ref="tplFormRef" :model="tplForm" :rules="tplFormRules" label-width="100px">
<el-form-item label="用户" prop="user_id">
<el-input
v-if="tplSelectedUser"
:model-value="`${tplSelectedUser.user_name} (ID: ${tplForm.user_id})`"
readonly
>
<template #suffix>
<el-icon v-if="tplDialogType === 'add'" class="clear-icon" @click="clearTplUser"><Close /></el-icon>
</template>
<template #append>
<el-button @click="tplUserSelectorVisible = true" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
<el-input v-else placeholder="请选择用户" readonly @click="openTplUserSelector">
<template #append>
<el-button @click="openTplUserSelector" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="请输入模板名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="推荐模板">
<el-select v-model="tplForm.recommended_id" placeholder="选择推荐模板(可选)" clearable style="width: 100%" @change="onRecommendedSelect">
<el-option v-for="r in recOptionsCache" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-form-item label="模板参数">
<div class="params-editor">
<div v-for="(p, idx) in tplForm.paramsList" :key="idx" class="param-row">
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="时间" value="date" />
</el-select>
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
<el-button type="danger" link @click="tplForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" link @click="tplForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
<el-icon><Plus /></el-icon> 添加参数
</el-button>
</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input v-model="tplForm.content" type="textarea" :rows="4" placeholder="如:您的验证码为{code},有效期{time}分钟。" />
<div v-if="tplForm.paramsList.length" class="insert-btns">
<span class="insert-label">插入参数</span>
<el-button v-for="p in tplForm.paramsList.filter(x => x.key)" :key="p.key" size="small" @click="insertParam(p.key)">{{ '{' + p.key + '}' }}</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="tplDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitTplForm">确定</el-button>
</template>
</el-dialog>
<!-- 推荐模板表单对话框 -->
<el-dialog v-model="recDialogVisible" :title="recDialogType === 'add' ? '新增推荐模板' : '编辑推荐模板'" width="600px" append-to-body destroy-on-close>
<el-form ref="recFormRef" :model="recForm" :rules="recFormRules" label-width="100px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="recForm.name" placeholder="请输入名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="分类">
<el-input v-model="recForm.category" placeholder="如:验证码、通知、营销" />
</el-form-item>
<el-form-item label="模板参数">
<div class="params-editor">
<div v-for="(p, idx) in recForm.paramsList" :key="idx" class="param-row">
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="时间" value="date" />
</el-select>
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
<el-button type="danger" link @click="recForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" link @click="recForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
<el-icon><Plus /></el-icon> 添加参数
</el-button>
</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input v-model="recForm.content" type="textarea" :rows="4" placeholder="模板内容,参数使用 {key} 格式" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="recDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitRecForm">确定</el-button>
</template>
</el-dialog>
<!-- 驳回对话框 -->
<el-dialog v-model="rejectDialogVisible" title="驳回模板" width="420px" append-to-body>
<el-form label-width="80px">
<el-form-item label="驳回原因">
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitting" @click="confirmRejectTpl">确认驳回</el-button>
</template>
</el-dialog>
<!-- 表单用户选择器 -->
<UserListSelector
v-model="tplUserSelectorVisible"
:current-user-id="tplForm.user_id"
@confirm="handleTplUserSelect"
/>
<!-- 筛选用户选择器 -->
<UserListSelector
v-model="tplFilterUserSelectorVisible"
:current-user-id="tplQuery.user_id ? Number(tplQuery.user_id) : undefined"
@confirm="handleTplFilterUserSelect"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, User, Close } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/tool'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import {
getSmsServiceList,
getSmsTemplateList, createSmsTemplate, updateSmsTemplate, deleteSmsTemplate,
submitSmsTemplate, approveSmsTemplate, rejectSmsTemplate,
getSmsRecommendedTemplateList, createSmsRecommendedTemplate, updateSmsRecommendedTemplate, deleteSmsRecommendedTemplate
} from '@/api/admin/smsService'
const serviceOptions = ref([])
const serviceId = ref(null)
const activeTab = ref('user')
const submitting = ref(false)
const statusText = (s) => ({ 0: '草稿', 1: '审核中', 2: '已通过', 3: '已驳回' }[s] || '未知')
const statusTagType = (s) => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }[s] || 'info')
// ========== 服务选择 ==========
const loadServices = async () => {
try {
const res = await getSmsServiceList({ page: 1, count: 199 })
if (res.data.code === 200) {
const list = res.data.data?.data || res.data.data || []
serviceOptions.value = Array.isArray(list) ? list : []
if (serviceOptions.value.length && !serviceId.value) {
serviceId.value = serviceOptions.value[0].id
}
}
} catch (e) { console.error(e) }
}
const onServiceChange = () => {
fetchTemplates()
fetchRecommended()
}
// ========== 用户模板 ==========
const tplLoading = ref(false)
const tplList = ref([])
const tplTotal = ref(0)
const tplQuery = reactive({ page: 1, count: 10, user_id: '', status: null })
const tplFilterUserSelectorVisible = ref(false)
const tplFilterUserInfo = ref(null)
const handleTplFilterUserSelect = (user) => {
tplQuery.user_id = String(user.user_id)
tplFilterUserInfo.value = user
fetchTemplates()
}
const clearTplFilterUser = () => {
tplQuery.user_id = ''
tplFilterUserInfo.value = null
fetchTemplates()
}
const fetchTemplates = async () => {
if (!serviceId.value) return
tplLoading.value = true
try {
const params = { service_id: serviceId.value, page: tplQuery.page, count: tplQuery.count }
if (tplQuery.user_id) params.user_id = Number(tplQuery.user_id)
if (tplQuery.status !== null && tplQuery.status !== '') params.status = tplQuery.status
const res = await getSmsTemplateList(params)
if (res.data.code === 200) {
const d = res.data.data
tplList.value = d?.data || d || []
tplTotal.value = d?.all_count || 0
}
} catch { ElMessage.error('获取模板列表失败') }
finally { tplLoading.value = false }
}
// 模板表单
const tplDialogVisible = ref(false)
const tplDialogType = ref('add')
const tplFormRef = ref(null)
const tplForm = reactive({ user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
const tplUserSelectorVisible = ref(false)
const tplSelectedUser = ref(null)
const openTplUserSelector = () => {
if (tplDialogType.value === 'edit') return
tplUserSelectorVisible.value = true
}
const handleTplUserSelect = (user) => {
tplForm.user_id = user.user_id
tplSelectedUser.value = user
}
const clearTplUser = () => {
tplForm.user_id = null
tplSelectedUser.value = null
}
const tplFormRules = {
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
const recOptionsCache = ref([])
const handleAddTemplate = () => {
tplDialogType.value = 'add'
tplSelectedUser.value = null
Object.assign(tplForm, { user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
loadRecOptionsCache()
tplDialogVisible.value = true
}
const handleEditTemplate = (row) => {
tplDialogType.value = 'edit'
tplSelectedUser.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
Object.assign(tplForm, { user_id: row.user_id, name: row.name, content: row.content, paramsList: params, recommended_id: row.recommended_id || null, template_id: row.ID })
loadRecOptionsCache()
tplDialogVisible.value = true
}
const loadRecOptionsCache = async () => {
if (!serviceId.value) return
try {
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: 1, count: 100 })
if (res.data.code === 200) {
recOptionsCache.value = res.data.data?.data || res.data.data || []
}
} catch {}
}
const onRecommendedSelect = (id) => {
if (!id) return
const rec = recOptionsCache.value.find(r => r.id === id)
if (rec) {
tplForm.content = rec.content
tplForm.paramsList = Array.isArray(rec.params) ? rec.params.map(p => ({ ...p })) : []
}
}
const insertParam = (key) => {
tplForm.content += `{${key}}`
}
const submitTplForm = () => {
tplFormRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const paramsJson = tplForm.paramsList.filter(p => p.key).length > 0
? JSON.stringify(tplForm.paramsList.filter(p => p.key))
: ''
const data = {
service_id: serviceId.value,
user_id: tplForm.user_id,
name: tplForm.name,
content: tplForm.content
}
if (paramsJson) data.params = paramsJson
if (tplForm.recommended_id) data.recommended_id = tplForm.recommended_id
let res
if (tplDialogType.value === 'add') {
res = await createSmsTemplate(data)
} else {
data.template_id = tplForm.template_id
res = await updateSmsTemplate(data)
}
if (res.data.code === 200) { ElMessage.success('操作成功'); tplDialogVisible.value = false; fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleSubmitTpl = (row) => {
ElMessageBox.confirm(`提交模板「${row.name}」审核?`, '提交审核', { type: 'info' }).then(async () => {
try {
const res = await submitSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已提交'); fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const handleApproveTpl = (row) => {
ElMessageBox.confirm(`通过模板「${row.name}」?`, '审核通过', { type: 'success' }).then(async () => {
try {
const res = await approveSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('已通过'); fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
}).catch(() => {})
}
const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const currentRejectRow = ref(null)
const handleRejectTpl = (row) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmRejectTpl = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请输入驳回原因'); return }
submitting.value = true
try {
const res = await rejectSmsTemplate({ service_id: serviceId.value, template_id: currentRejectRow.value.ID, reject_reason: rejectReason.value })
if (res.data.code === 200) { ElMessage.success('已驳回'); rejectDialogVisible.value = false; fetchTemplates() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
}
const handleDeleteTpl = (row) => {
ElMessageBox.confirm(`确认删除模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchTemplates() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
// ========== 推荐模板 ==========
const recLoading = ref(false)
const recList = ref([])
const recTotal = ref(0)
const recQuery = reactive({ page: 1, count: 10 })
const fetchRecommended = async () => {
if (!serviceId.value) return
recLoading.value = true
try {
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: recQuery.page, count: recQuery.count })
if (res.data.code === 200) {
const d = res.data.data
recList.value = d?.data || d || []
recTotal.value = d?.all_count || 0
}
} catch { ElMessage.error('获取推荐模板失败') }
finally { recLoading.value = false }
}
const recDialogVisible = ref(false)
const recDialogType = ref('add')
const recFormRef = ref(null)
const recForm = reactive({ name: '', content: '', paramsList: [], category: '', template_id: null })
const recFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
}
const handleAddRecommended = () => {
recDialogType.value = 'add'
Object.assign(recForm, { name: '', content: '', paramsList: [], category: '', template_id: null })
recDialogVisible.value = true
}
const handleEditRecommended = (row) => {
recDialogType.value = 'edit'
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
Object.assign(recForm, { name: row.name, content: row.content, paramsList: params, category: row.category || '', template_id: row.id })
recDialogVisible.value = true
}
const submitRecForm = () => {
recFormRef.value?.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const paramsJson = recForm.paramsList.filter(p => p.key).length > 0
? JSON.stringify(recForm.paramsList.filter(p => p.key))
: ''
const data = {
service_id: serviceId.value,
name: recForm.name,
content: recForm.content
}
if (paramsJson) data.params = paramsJson
if (recForm.category) data.category = recForm.category
let res
if (recDialogType.value === 'add') {
res = await createSmsRecommendedTemplate(data)
} else {
data.template_id = recForm.template_id
res = await updateSmsRecommendedTemplate(data)
}
if (res.data.code === 200) { ElMessage.success('操作成功'); recDialogVisible.value = false; fetchRecommended() }
else { ElMessage.error(res.data.message || '操作失败') }
} catch { ElMessage.error('操作失败') }
finally { submitting.value = false }
})
}
const handleDeleteRecommended = (row) => {
ElMessageBox.confirm(`确认删除推荐模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
try {
const res = await deleteSmsRecommendedTemplate({ service_id: serviceId.value, template_id: row.id })
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchRecommended() }
else { ElMessage.error(res.data.message || '删除失败') }
} catch { ElMessage.error('删除失败') }
}).catch(() => {})
}
// ========== 初始化 ==========
onMounted(async () => {
await loadServices()
fetchTemplates()
fetchRecommended()
})
</script>
<style scoped>
.sms-template-page { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-info { display: flex; align-items: center; gap: 14px; }
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: #ecf5ff; display: flex; align-items: center; justify-content: center; }
.header-title { margin: 0; font-size: 20px; color: #303133; }
.header-desc { margin: 4px 0 0; font-size: 13px; color: #909399; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; }
.tab-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.tab-filters { display: flex; gap: 10px; align-items: center; }
.pagination { margin-top: 16px; justify-content: flex-end; }
.content-cell { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; font-size: 13px; cursor: pointer; }
.reject-text { color: #f56c6c; font-size: 12px; }
.text-muted { color: #c0c4cc; }
.params-editor { width: 100%; }
.param-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.insert-btns { margin-top: 8px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.insert-label { font-size: 12px; color: #909399; }
.clear-icon { cursor: pointer; color: #909399; transition: color 0.2s; }
.clear-icon:hover { color: #f56c6c; }
</style>