feat(okr): Phase2 - 行业扩展包12个+OKR模板库页面
- 插入12个行业包模板:贸易经销(7)+IT服务(5) - 后端: name搜索+apply端点 - 前端: OKRTemplates.vue页面(搜索/筛选/三分区/应用弹窗) - 路由+菜单+权限配置
This commit is contained in:
@@ -6,22 +6,44 @@ from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import OKRTemplate
|
||||
from app.models import OKRTemplate, StrategicMap
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
DIMENSION_LAYER_NAMES = {
|
||||
"finance": "财务层",
|
||||
"customer": "客户层",
|
||||
"process": "内部流程层",
|
||||
"learning": "学习成长层",
|
||||
}
|
||||
|
||||
DIMENSION_LAYER_ICONS = {
|
||||
"finance": "💰",
|
||||
"customer": "👥",
|
||||
"process": "⚙️",
|
||||
"learning": "📚",
|
||||
}
|
||||
|
||||
DIMENSION_LAYER_COLORS = {
|
||||
"finance": "#F56C6C",
|
||||
"customer": "#409EFF",
|
||||
"process": "#67C23A",
|
||||
"learning": "#E6A23C",
|
||||
}
|
||||
|
||||
@router.get("")
|
||||
def list_okr_templates(
|
||||
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
||||
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
||||
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
||||
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
|
||||
active_only: bool = Query(True, description="仅返回启用模板"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出 OKR 模板,支持按维度筛选"""
|
||||
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索"""
|
||||
q = db.query(OKRTemplate)
|
||||
if dimension:
|
||||
q = q.filter(OKRTemplate.dimension == dimension)
|
||||
@@ -29,6 +51,8 @@ def list_okr_templates(
|
||||
q = q.filter(OKRTemplate.source == source)
|
||||
if industry_tag:
|
||||
q = q.filter(OKRTemplate.industry_tag == industry_tag)
|
||||
if search:
|
||||
q = q.filter(OKRTemplate.name.like(f"%{search}%"))
|
||||
if active_only:
|
||||
q = q.filter(OKRTemplate.is_active == 1)
|
||||
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
|
||||
@@ -113,3 +137,67 @@ def increment_use_count(template_id: int, db: Session = Depends(get_db)):
|
||||
t.use_count = (t.use_count or 0) + 1
|
||||
db.commit()
|
||||
return {"ok": True, "use_count": t.use_count}
|
||||
|
||||
|
||||
@router.post("/{template_id}/apply")
|
||||
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
|
||||
map_title = data.get("title", t.name)
|
||||
dim = t.dimension
|
||||
preset_krs = t.preset_krs or []
|
||||
|
||||
# 构建 dimensions: 仅包含模板所在的维度层
|
||||
dimensions = []
|
||||
for dk in ("finance", "customer", "process", "learning"):
|
||||
objectives = []
|
||||
if dk == dim:
|
||||
objectives.append({
|
||||
"name": t.name,
|
||||
"description": t.description or "",
|
||||
"kpis": [],
|
||||
"krs": [
|
||||
{
|
||||
"name": kr.get("name", ""),
|
||||
"target_value": kr.get("target_value", ""),
|
||||
"weight": kr.get("weight", 33),
|
||||
}
|
||||
for kr in preset_krs
|
||||
],
|
||||
})
|
||||
dimensions.append({
|
||||
"key": dk,
|
||||
"name": DIMENSION_LAYER_NAMES.get(dk, dk),
|
||||
"icon": DIMENSION_LAYER_ICONS.get(dk, "📌"),
|
||||
"color": DIMENSION_LAYER_COLORS.get(dk, "#909399"),
|
||||
"objectives": objectives,
|
||||
})
|
||||
|
||||
m = StrategicMap(
|
||||
title=map_title,
|
||||
version=data.get("version", "v1.0"),
|
||||
status="draft",
|
||||
dimensions=dimensions,
|
||||
canvas_data={"connections": []},
|
||||
)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
|
||||
# 使用 maps API 的 _sync_map_objectives 同步到 map_objectives 表
|
||||
from app.api.maps import _sync_map_objectives
|
||||
_sync_map_objectives(m, db)
|
||||
|
||||
# 增加模板使用次数
|
||||
t.use_count = (t.use_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"map_id": m.id,
|
||||
"title": m.title,
|
||||
"template_id": template_id,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""OKR模板库 Phase 2 — 行业扩展包(12个模板)
|
||||
source='industry_pack'
|
||||
"""
|
||||
import sys, os, logging
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import OKRTemplate
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("seed_industry_packs")
|
||||
|
||||
# ── 贸易经销包(7个,industry_tag='trading')─
|
||||
# PRD参考:优化渠补结构 / 加速应收周转 / 建立渠道分级体系 / 提升核心渠道忠诚度 / 建立渠补谈判SOP / 进销存系统全覆盖 / 渠道赋能培训
|
||||
TRADING_TEMPLATES = [
|
||||
{
|
||||
"name": "优化渠补结构",
|
||||
"description": "优化渠道补贴结构,提升渠道投入产出比",
|
||||
"dimension": "finance",
|
||||
"layer": "level2",
|
||||
"industry_tag": "trading",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 101,
|
||||
"preset_krs": [
|
||||
{"name": "渠补率≤75%", "target_value": "≤75%", "weight": 40, "sort_order": 1},
|
||||
{"name": "渠补成本下降≥20%", "target_value": "≥20%", "weight": 30, "sort_order": 2},
|
||||
{"name": "渠道综合利润率提升≥5%", "target_value": "≥5%", "weight": 30, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "加速应收周转",
|
||||
"description": "加速应收账款周转,改善现金流健康度",
|
||||
"dimension": "finance",
|
||||
"layer": "level2",
|
||||
"industry_tag": "trading",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 102,
|
||||
"preset_krs": [
|
||||
{"name": "应收账款周转天数≤30天", "target_value": "≤30天", "weight": 40, "sort_order": 1},
|
||||
{"name": "逾期账款占比≤5%", "target_value": "≤5%", "weight": 30, "sort_order": 2},
|
||||
{"name": "回款及时率≥95%", "target_value": "≥95%", "weight": 30, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "建立渠道分级体系",
|
||||
"description": "建立科学渠道分级管理体系,差异化赋能",
|
||||
"dimension": "customer",
|
||||
"layer": "level2",
|
||||
"industry_tag": "trading",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 103,
|
||||
"preset_krs": [
|
||||
{"name": "渠道分级覆盖率100%", "target_value": "100%", "weight": 34, "sort_order": 1},
|
||||
{"name": "A级渠道占比≥30%", "target_value": "≥30%", "weight": 33, "sort_order": 2},
|
||||
{"name": "分级规则满意度≥85%", "target_value": "≥85%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "提升核心渠道忠诚度",
|
||||
"description": "提升核心渠道伙伴忠诚度和合作粘性",
|
||||
"dimension": "customer",
|
||||
"layer": "level2",
|
||||
"industry_tag": "trading",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 104,
|
||||
"preset_krs": [
|
||||
{"name": "核心渠道流失率≤3%", "target_value": "≤3%", "weight": 34, "sort_order": 1},
|
||||
{"name": "核心渠道续约率≥90%", "target_value": "≥90%", "weight": 33, "sort_order": 2},
|
||||
{"name": "渠道满意度评分≥4.5分", "target_value": "≥4.5分", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "建立渠补谈判SOP",
|
||||
"description": "建立标准化渠补谈判流程与SOP,减少灰色地带",
|
||||
"dimension": "process",
|
||||
"layer": "level2",
|
||||
"industry_tag": "trading",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 105,
|
||||
"preset_krs": [
|
||||
{"name": "渠补谈判SOP覆盖率100%", "target_value": "100%", "weight": 34, "sort_order": 1},
|
||||
{"name": "谈判周期缩短≥30%", "target_value": "≥30%", "weight": 33, "sort_order": 2},
|
||||
{"name": "渠补争议事件≤3起/季", "target_value": "≤3起/季", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "进销存系统全覆盖",
|
||||
"description": "实现进销存系统全渠道覆盖,数据实时可视",
|
||||
"dimension": "process",
|
||||
"layer": "level2",
|
||||
"industry_tag": "trading",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 106,
|
||||
"preset_krs": [
|
||||
{"name": "进销存系统覆盖率≥90%", "target_value": "≥90%", "weight": 34, "sort_order": 1},
|
||||
{"name": "库存数据实时更新率100%", "target_value": "100%", "weight": 33, "sort_order": 2},
|
||||
{"name": "进销存报表自动生成率≥80%", "target_value": "≥80%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "渠道赋能培训",
|
||||
"description": "系统化渠道伙伴赋能培训,提升渠道综合能力",
|
||||
"dimension": "learning",
|
||||
"layer": "level2",
|
||||
"industry_tag": "trading",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 107,
|
||||
"preset_krs": [
|
||||
{"name": "渠道培训覆盖率≥80%", "target_value": "≥80%", "weight": 34, "sort_order": 1},
|
||||
{"name": "培训考核通过率≥85%", "target_value": "≥85%", "weight": 33, "sort_order": 2},
|
||||
{"name": "培训后渠道业绩提升≥10%", "target_value": "≥10%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# ── IT服务包(5个,industry_tag='it_service')─
|
||||
# PRD参考:提升人均产值 / 提高NPS净推荐值 / 知识管理体系化 / 交付标准化 / 工程师认证体系
|
||||
ITSERVICE_TEMPLATES = [
|
||||
{
|
||||
"name": "提升人均产值",
|
||||
"description": "提升技术团队人均产值和项目利润率",
|
||||
"dimension": "finance",
|
||||
"layer": "level2",
|
||||
"industry_tag": "it_service",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 201,
|
||||
"preset_krs": [
|
||||
{"name": "人均产值≥30万/季", "target_value": "≥30万/季", "weight": 40, "sort_order": 1},
|
||||
{"name": "项目毛利率≥25%", "target_value": "≥25%", "weight": 30, "sort_order": 2},
|
||||
{"name": "人效同比增长≥15%", "target_value": "≥15%", "weight": 30, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "提高NPS净推荐值",
|
||||
"description": "提高客户NPS净推荐值,建立良好口碑",
|
||||
"dimension": "customer",
|
||||
"layer": "level2",
|
||||
"industry_tag": "it_service",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 202,
|
||||
"preset_krs": [
|
||||
{"name": "NPS得分≥70", "target_value": "≥70", "weight": 34, "sort_order": 1},
|
||||
{"name": "客户满意度评分≥4.5分", "target_value": "≥4.5分", "weight": 33, "sort_order": 2},
|
||||
{"name": "客户推荐率≥40%", "target_value": "≥40%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "交付标准化",
|
||||
"description": "实现项目交付流程标准化和可复制",
|
||||
"dimension": "process",
|
||||
"layer": "level2",
|
||||
"industry_tag": "it_service",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 203,
|
||||
"preset_krs": [
|
||||
{"name": "交付SOP覆盖率100%", "target_value": "100%", "weight": 34, "sort_order": 1},
|
||||
{"name": "项目按时交付率≥90%", "target_value": "≥90%", "weight": 33, "sort_order": 2},
|
||||
{"name": "项目验收一次通过率≥85%", "target_value": "≥85%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "知识管理体系化",
|
||||
"description": "建设体系化知识管理平台,沉淀项目经验",
|
||||
"dimension": "learning",
|
||||
"layer": "level2",
|
||||
"industry_tag": "it_service",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 204,
|
||||
"preset_krs": [
|
||||
{"name": "知识库文章数量≥500篇", "target_value": "≥500篇", "weight": 34, "sort_order": 1},
|
||||
{"name": "知识复用率≥40%", "target_value": "≥40%", "weight": 33, "sort_order": 2},
|
||||
{"name": "知识贡献覆盖率≥80%", "target_value": "≥80%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "工程师认证体系",
|
||||
"description": "建立工程师技术认证与职业成长体系",
|
||||
"dimension": "learning",
|
||||
"layer": "level2",
|
||||
"industry_tag": "it_service",
|
||||
"source": "industry_pack",
|
||||
"sort_order": 205,
|
||||
"preset_krs": [
|
||||
{"name": "认证覆盖率≥60%", "target_value": "≥60%", "weight": 34, "sort_order": 1},
|
||||
{"name": "高级工程师占比≥30%", "target_value": "≥30%", "weight": 33, "sort_order": 2},
|
||||
{"name": "认证通过率≥80%", "target_value": "≥80%", "weight": 33, "sort_order": 3},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
ALL_INDUSTRY_TEMPLATES = TRADING_TEMPLATES + ITSERVICE_TEMPLATES
|
||||
|
||||
|
||||
def seed_industry_packs():
|
||||
"""插入行业包模板(如果不存在)"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
existing = db.query(OKRTemplate).filter(OKRTemplate.source == "industry_pack").count()
|
||||
if existing > 0:
|
||||
logger.info(f"已有 {existing} 条行业包模板,跳过")
|
||||
return
|
||||
for item in ALL_INDUSTRY_TEMPLATES:
|
||||
t = OKRTemplate(**item)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
logger.info(f"✔ 已插入 {len(ALL_INDUSTRY_TEMPLATES)} 条行业包模板(贸易经销7 + IT服务5)")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"行业包种子数据插入失败: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_industry_packs()
|
||||
logger.info("OKR行业扩展包初始化完成")
|
||||
@@ -71,6 +71,14 @@ export const templateApi = {
|
||||
delete: (id: number) => api.delete(`/templates/${id}`),
|
||||
}
|
||||
|
||||
export const okrTemplateApi = {
|
||||
list: (params?: any) => api.get('/okr-templates', { params }),
|
||||
get: (id: number) => api.get(`/okr-templates/${id}`),
|
||||
create: (data: any) => api.post('/okr-templates', data),
|
||||
incrementUse: (id: number) => api.post(`/okr-templates/${id}/use`),
|
||||
applyTemplate: (id: number, data: any) => api.post(`/okr-templates/${id}/apply`, data),
|
||||
}
|
||||
|
||||
export const dataApi = {
|
||||
importExcel: (file: File, qs?: string) => {
|
||||
const form = new FormData()
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
// 各角色可访问的路由列表
|
||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis'],
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/okr-templates'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/okr-templates'],
|
||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/deviations', '/budget', '/action-plans', '/knowledge', '/guide', '/customer', '/reports', '/alignment'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/okr-templates'],
|
||||
}
|
||||
|
||||
// 可操作的CRUD权限
|
||||
@@ -67,6 +67,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
// GROUP 5: 系统与支持(Infra)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
{ path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '系统与支持' },
|
||||
{ path: '/okr-templates', label: 'OKR模板库', icon: 'Collection', roles: ['ceo', 'finance', 'it'], group: '系统与支持' },
|
||||
{ path: '/guide', label: '新手引导', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'], group: '系统与支持' },
|
||||
{ path: '/org', label: '组织管理', icon: 'Collection', roles: ['ceo', 'it'], group: '系统与支持' },
|
||||
{ path: '/users', label: '用户管理', icon: 'User', roles: ['ceo', 'it'], group: '系统与支持' },
|
||||
|
||||
@@ -34,6 +34,7 @@ const routes = [
|
||||
{ path: 'dupont-analysis', name: 'DupontAnalysis', component: () => import('@/views/DupontAnalysis.vue'), meta: { title: '杜邦分析', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'data-quality', name: 'DataQuality', component: () => import('@/views/DataQuality.vue'), meta: { title: '数据质量', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } },
|
||||
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'] } },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
<template>
|
||||
<div class="okr-templates-page">
|
||||
<div class="page-header">
|
||||
<h3>OKR模板库</h3>
|
||||
<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-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>
|
||||
|
||||
<!-- 应用弹窗 — 选择目标地图 -->
|
||||
<el-dialog v-model="showApplyDialog" title="应用模板" width="480px" destroy-on-close>
|
||||
<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>
|
||||
</el-dialog>
|
||||
</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'
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user