Files
cma-management/frontend/src/views/OKRTemplates.vue
T
wecom-fullstack f51fc72ac7
ci/woodpecker/push/main Pipeline failed
style(page-title): B 全站规范——页面内同名标题清零 + 增量副标题弱化 + 标题 emoji 换 Lucide
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处)
- 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标
- 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history)
- KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头
- 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
2026-10-18 19:33:11 +08:00

392 lines
12 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="okr-templates-page">
<div class="page-header">
<p class="page-desc">从模板库中选择O+KR模板,快速应用到战略地图画布</p>
</div>
<!-- 搜索与筛选 -->
<div class="filter-bar">
<el-input
v-model="searchText"
placeholder="搜索O名称..."
clearable
prefix-icon="Search"
style="width:260px"
@input="onSearch"
/>
<el-select v-model="filterIndustry" placeholder="行业" clearable style="width:150px" @change="loadTemplates">
<el-option label="全部行业" value="" />
<el-option label="贸易经销" value="trading" />
<el-option label="IT服务" value="it_service" />
</el-select>
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:150px" @change="loadTemplates">
<el-option label="全部维度" value="" />
<el-option label="财务层" value="finance" />
<el-option label="客户层" value="customer" />
<el-option label="流程层" value="process" />
<el-option label="学习成长层" value="learning" />
</el-select>
<el-button type="primary" :icon="Search" @click="loadTemplates">搜索</el-button>
</div>
<!-- 统计 -->
<div class="stat-bar">
<el-tag type="">{{ systemCount }} 个系统模板</el-tag>
<el-tag type="warning">{{ industryCount }} 个行业包</el-tag>
<el-tag type="success">{{ userCount }} 个用户自定义</el-tag>
</div>
<!-- 加载中 -->
<div v-if="loading" style="text-align:center;padding:40px"><el-icon class="is-loading" :size="24"><Loading /></el-icon> 加载中...</div>
<!-- 系统模板分区 -->
<div v-if="!loading">
<div class="section-title">
<el-icon color="#409EFF"><Document /></el-icon> 系统模板 <span class="section-badge">({{ systemTemplates.length }})</span>
</div>
<div v-if="systemTemplates.length === 0" class="empty-section">暂无匹配的系统模板</div>
<div v-else class="template-grid">
<div v-for="t in systemTemplates" :key="t.id" class="template-card">
<div class="card-icon" :style="{ background: dimColor(t.dimension) + '20', color: dimColor(t.dimension) }">
{{ dimIcon(t.dimension) }}
</div>
<div class="card-body">
<div class="card-name">{{ t.name }}</div>
<div class="card-dim tag">{{ dimLabel(t.dimension) }}</div>
<div class="card-desc">{{ t.description }}</div>
<div class="card-footer">
<span class="use-count">已使用 {{ t.use_count || 0 }} 次</span>
<el-tag v-if="t.owner" size="small" type="info" effect="plain">{{ t.owner }}</el-tag>
<el-button size="small" type="primary" @click="applyTemplate(t)">应用</el-button>
</div>
</div>
</div>
</div>
<!-- 行业包分区 -->
<div class="section-title" style="margin-top:28px;">
<el-icon color="#E6A23C"><Files /></el-icon> 行业扩展包 <span class="section-badge">({{ industryTemplates.length }})</span>
</div>
<div v-if="industryTemplates.length === 0" class="empty-section">暂无匹配的行业包模板</div>
<div v-else class="template-grid">
<div v-for="t in industryTemplates" :key="t.id" class="template-card card-industry">
<div v-if="t.industry_tag === 'trading'" class="industry-badge trading">贸易经销</div>
<div v-if="t.industry_tag === 'it_service'" class="industry-badge it">IT服务</div>
<div class="card-icon" :style="{ background: dimColor(t.dimension) + '20', color: dimColor(t.dimension) }">
{{ dimIcon(t.dimension) }}
</div>
<div class="card-body">
<div class="card-name">{{ t.name }}</div>
<div class="card-dim tag">{{ dimLabel(t.dimension) }}</div>
<div class="card-desc">{{ t.description }}</div>
<div class="card-footer">
<span class="use-count">已使用 {{ t.use_count || 0 }} 次</span>
<el-button size="small" type="warning" @click="applyTemplate(t)">应用</el-button>
</div>
</div>
</div>
</div>
<!-- 用户自定义分区 -->
<div class="section-title" style="margin-top:28px;">
<el-icon color="#67C23A"><User /></el-icon> 用户自定义 <span class="section-badge">({{ userTemplates.length }})</span>
</div>
<div v-if="userTemplates.length === 0" class="empty-section">暂无用户自定义模板 — 在画布中保存O+KR后将自动生成</div>
<div v-else class="template-grid">
<div v-for="t in userTemplates" :key="t.id" class="template-card card-user">
<div class="card-icon" :style="{ background: dimColor(t.dimension) + '20', color: dimColor(t.dimension) }">
{{ dimIcon(t.dimension) }}
</div>
<div class="card-body">
<div class="card-name">{{ t.name }}</div>
<div class="card-dim tag">{{ dimLabel(t.dimension) }}</div>
<div class="card-desc">{{ t.description }}</div>
<div class="card-footer">
<span class="use-count">已使用 {{ t.use_count || 0 }} 次</span>
<el-button size="small" type="success" @click="applyTemplate(t)">应用</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 应用弹窗 — 选择目标地图 -->
<MyDialog v-model="showApplyDialog" title="应用模板" :width="480">
<div v-if="applyingTemplate">
<p style="margin-bottom:12px;font-weight:500;">O:{{ applyingTemplate.name }}</p>
<p style="margin-bottom:12px;color:#909399;font-size:13px;">
维度:{{ dimLabel(applyingTemplate.dimension) }} |
KR数量:{{ applyingTemplate.preset_krs?.length || 0 }}
</p>
<el-divider />
<el-form label-width="100px">
<el-form-item label="地图名称">
<el-input v-model="applyMapTitle" placeholder="输入新地图名称" />
</el-form-item>
</el-form>
<div style="font-size:12px;color:#909399;margin-top:8px;">
⚡ 将创建一张新战略地图并自动填入该O+KR,之后可在画布中继续编辑
</div>
</div>
<template #footer>
<el-button size="small" @click="showApplyDialog = false">取消</el-button>
<el-button size="small" type="primary" :loading="applying" @click="confirmApply">确认应用</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Document, Files, User, Loading } from '@element-plus/icons-vue'
import { okrTemplateApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const router = useRouter()
const loading = ref(true)
const allTemplates = ref<any[]>([])
const searchText = ref('')
const filterIndustry = ref('')
const filterDimension = ref('')
const showApplyDialog = ref(false)
const applyingTemplate = ref<any>(null)
const applyMapTitle = ref('')
const applying = ref(false)
// 维度映射
const DIM_LABELS: Record<string, string> = {
finance: '财务层',
customer: '客户层',
process: '流程层',
learning: '学习成长层',
}
const DIM_ICONS: Record<string, string> = {
finance: '💰',
customer: '👥',
process: '⚙️',
learning: '📚',
}
const DIM_COLORS: Record<string, string> = {
finance: '#F56C6C',
customer: '#409EFF',
process: '#67C23A',
learning: '#E6A23C',
}
function dimLabel(d: string) { return DIM_LABELS[d] || d }
function dimIcon(d: string) { return DIM_ICONS[d] || '📌' }
function dimColor(d: string) { return DIM_COLORS[d] || '#909399' }
const debounceTimer = ref<ReturnType<typeof setTimeout> | null>(null)
function onSearch() {
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => loadTemplates(), 300)
}
async function loadTemplates() {
loading.value = true
try {
const params: Record<string, any> = {}
if (filterIndustry.value) params.industry_tag = filterIndustry.value
if (filterDimension.value) params.dimension = filterDimension.value
if (searchText.value.trim()) params.search = searchText.value.trim()
const res: any = await okrTemplateApi.list(params)
allTemplates.value = res?.items || []
} catch (e: any) {
ElMessage.error('加载模板失败: ' + (e?.message || ''))
allTemplates.value = []
} finally {
loading.value = false
}
}
const systemTemplates = computed(() =>
allTemplates.value.filter(t => t.source === 'system')
)
const industryTemplates = computed(() =>
allTemplates.value.filter(t => t.source === 'industry_pack')
)
const userTemplates = computed(() =>
allTemplates.value.filter(t => t.source === 'user')
)
const systemCount = computed(() => systemTemplates.value.length)
const industryCount = computed(() => industryTemplates.value.length)
const userCount = computed(() => userTemplates.value.length)
function applyTemplate(t: any) {
applyingTemplate.value = t
applyMapTitle.value = t.name + ' — 战略地图'
showApplyDialog.value = true
}
async function confirmApply() {
if (!applyMapTitle.value.trim()) {
ElMessage.warning('请输入地图名称')
return
}
applying.value = true
try {
const res: any = await okrTemplateApi.applyTemplate(applyingTemplate.value.id, {
title: applyMapTitle.value.trim(),
})
ElMessage.success('模板已应用,正在跳转到画布...')
showApplyDialog.value = false
// 刷新列表以更新 use_count
loadTemplates()
// 跳转到画布
if (res?.map_id) {
router.push('/maps/canvas/' + res.map_id)
}
} catch (e: any) {
ElMessage.error('应用失败: ' + (e?.response?.data?.detail || e?.message || ''))
} finally {
applying.value = false
}
}
onMounted(() => {
loadTemplates()
})
</script>
<style scoped>
.okr-templates-page {
padding: 16px 20px;
}
.page-header h3 {
margin: 0 0 4px;
font-size: 18px;
}
.page-desc {
margin: 0 0 16px;
color: #909399;
font-size: 13px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.stat-bar {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.section-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.section-badge {
font-weight: 400;
color: #909399;
font-size: 13px;
}
.empty-section {
color: #C0C4CC;
font-size: 13px;
padding: 20px 0;
text-align: center;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.template-card {
position: relative;
background: #fff;
border: 1px solid #EBEEF5;
border-radius: 8px;
padding: 16px;
display: flex;
gap: 14px;
transition: box-shadow 0.2s;
}
.template-card:hover {
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.card-industry {
border-left: 3px solid #E6A23C;
}
.card-user {
border-left: 3px solid #67C23A;
}
.industry-badge {
position: absolute;
top: 8px;
right: 8px;
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
font-weight: 500;
}
.industry-badge.trading {
background: #FDF6EC;
color: #E6A23C;
border: 1px solid #E6A23C;
}
.industry-badge.it {
background: #ECF5FF;
color: #409EFF;
border: 1px solid #409EFF;
}
.card-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-name {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.card-dim.tag {
display: inline-block;
font-size: 11px;
padding: 0 6px;
border-radius: 4px;
background: #F5F7FA;
color: #606266;
margin-bottom: 6px;
}
.card-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.use-count {
font-size: 12px;
color: #C0C4CC;
}
</style>