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 typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_role
|
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模板库"],
|
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
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("")
|
@router.get("")
|
||||||
def list_okr_templates(
|
def list_okr_templates(
|
||||||
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
||||||
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
||||||
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
||||||
|
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
|
||||||
active_only: bool = Query(True, description="仅返回启用模板"),
|
active_only: bool = Query(True, description="仅返回启用模板"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""列出 OKR 模板,支持按维度筛选"""
|
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索"""
|
||||||
q = db.query(OKRTemplate)
|
q = db.query(OKRTemplate)
|
||||||
if dimension:
|
if dimension:
|
||||||
q = q.filter(OKRTemplate.dimension == dimension)
|
q = q.filter(OKRTemplate.dimension == dimension)
|
||||||
@@ -29,6 +51,8 @@ def list_okr_templates(
|
|||||||
q = q.filter(OKRTemplate.source == source)
|
q = q.filter(OKRTemplate.source == source)
|
||||||
if industry_tag:
|
if industry_tag:
|
||||||
q = q.filter(OKRTemplate.industry_tag == industry_tag)
|
q = q.filter(OKRTemplate.industry_tag == industry_tag)
|
||||||
|
if search:
|
||||||
|
q = q.filter(OKRTemplate.name.like(f"%{search}%"))
|
||||||
if active_only:
|
if active_only:
|
||||||
q = q.filter(OKRTemplate.is_active == 1)
|
q = q.filter(OKRTemplate.is_active == 1)
|
||||||
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
|
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
|
t.use_count = (t.use_count or 0) + 1
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "use_count": t.use_count}
|
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}`),
|
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 = {
|
export const dataApi = {
|
||||||
importExcel: (file: File, qs?: string) => {
|
importExcel: (file: File, qs?: string) => {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
|
|
||||||
// 各角色可访问的路由列表
|
// 各角色可访问的路由列表
|
||||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
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'],
|
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'],
|
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'],
|
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权限
|
// 可操作的CRUD权限
|
||||||
@@ -67,6 +67,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
// GROUP 5: 系统与支持(Infra)
|
// GROUP 5: 系统与支持(Infra)
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
{ path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '系统与支持' },
|
{ 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: '/guide', label: '新手引导', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'], group: '系统与支持' },
|
||||||
{ path: '/org', label: '组织管理', icon: 'Collection', roles: ['ceo', 'it'], group: '系统与支持' },
|
{ path: '/org', label: '组织管理', icon: 'Collection', roles: ['ceo', 'it'], group: '系统与支持' },
|
||||||
{ path: '/users', label: '用户管理', icon: 'User', 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: '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: '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: '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