Files
cma-management/frontend/src/views/StrategyCharter.vue
T
wecom-fullstack 0a6525edab
ci/woodpecker/push/main Pipeline was successful
feat(strategy): P0 战略基座 — strategy_charter + GET/PUT /api/cma/charter + P组入口
- 表 strategy_charter(一账套一行,entity_id 唯一):mission/vision/vision_year/vision_measurable/values_text/value_items(JSON) 全 NOT NULL
- 迁移脚本 backend/scripts/migrate_strategy_charter_20261021.py:--check/--apply/--down(回滚先 mysqldump,有数据需 --force),零 INSERT
- API:GET 无行 → filled=false 空态(禁示例数据);PUT 字段白名单 + 账套隔离三道 + vision_year 4位年份 + value_items [{name,meaning}] 结构校验
- 前端:P 组「战略章程」入口 + MyDialog 编辑(ceo/finance 可写,其余只读);战略地图页顶部只读章程条
- 用例 43 条 + auth-surface 58 条 = 101 passed;vite build rc=0
2026-10-21 18:54:09 +08:00

289 lines
13 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="charter-page">
<div class="charter-head">
<div class="charter-title">
<h2>战略章程</h2>
<p>P 战略基座 · 使命 / 愿景(年度 + 可验证状态)/ 价值观 —— 全公司战略口径的唯一起点</p>
</div>
<div class="charter-actions">
<el-button
v-if="canEdit"
type="primary"
:icon="Edit"
:disabled="loading"
@click="openEdit"
>{{ filled ? '修订章程' : '制定章程' }}</el-button>
<el-button v-else :icon="Lock" disabled title="仅经营层(总经理 / 财务部)可修订章程">只读</el-button>
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<!-- 空态:库里没有章程行 —— 不显示任何示例文本(派单红线) -->
<div v-else-if="!filled" class="charter-empty">
<el-icon :size="40"><Opportunity /></el-icon>
<h3>尚未填写战略章程</h3>
<p>本账套还没有使命 / 愿景 / 价值观记录。请由经营层录入第一条,系统不做任何预填。</p>
<el-button v-if="canEdit" type="primary" :icon="Edit" @click="openEdit">录入章程</el-button>
</div>
<template v-else>
<div class="charter-cards">
<section class="charter-card">
<div class="card-label"><el-icon><Aim /></el-icon>使命<span>Mission</span></div>
<p class="card-text">{{ data?.mission }}</p>
</section>
<section class="charter-card">
<div class="card-label">
<el-icon><Sunrise /></el-icon>愿景<span>Vision</span>
<el-tag size="small" effect="plain" class="year-tag">{{ data?.vision_year }}</el-tag>
</div>
<p class="card-text">{{ data?.vision }}</p>
<div class="measurable">
<span class="measurable-k">可验证状态</span>
<span class="measurable-v">{{ data?.vision_measurable }}</span>
</div>
</section>
<section class="charter-card charter-card--wide">
<div class="card-label"><el-icon><Star /></el-icon>价值观<span>Core Values</span></div>
<p class="card-text">{{ data?.values_text }}</p>
<ul class="value-list">
<li v-for="(item, idx) in data?.value_items || []" :key="idx">
<span class="value-name">{{ item.name }}</span>
<span class="value-meaning">{{ item.meaning }}</span>
</li>
</ul>
</section>
</div>
<div class="charter-meta">
<div class="meta-item">
<span class="meta-k">最后修订</span>
<span class="meta-v">{{ data?.updated_by_name || '—' }} · {{ data?.updated_at || '—' }}</span>
</div>
<div class="meta-item">
<span class="meta-k">创建时间</span>
<span class="meta-v">{{ data?.created_at || '—' }}</span>
</div>
</div>
</template>
<!-- 项目铁律:业务弹窗一律 MyDialog(禁 el-dialog / ElMessageBox 确认) -->
<MyDialog v-model="dialogVisible" :title="filled ? '修订战略章程' : '制定战略章程'" :width="760">
<el-form :model="form" label-width="110px" label-position="left" class="charter-form">
<el-form-item label="使命" required>
<el-input
v-model="form.mission"
type="textarea"
:rows="2"
maxlength="1000"
show-word-limit
placeholder="我们为何存在:做什么、为谁做"
/>
</el-form-item>
<el-form-item label="愿景" required>
<el-input
v-model="form.vision"
type="textarea"
:rows="2"
maxlength="1000"
show-word-limit
placeholder="要成为什么"
/>
</el-form-item>
<el-form-item label="愿景年度" required>
<el-input v-model="form.vision_year" maxlength="4" placeholder="如 2030" class="year-input" />
<span class="form-hint">不可空;4 位年份</span>
</el-form-item>
<el-form-item label="可验证状态" required>
<el-input
v-model="form.vision_measurable"
type="textarea"
:rows="2"
maxlength="1000"
show-word-limit
placeholder="用可核对的规模/业务口径描述,如「2030 年服务 300 家付费企业、续约率 ≥85%」"
/>
<span class="form-hint">不可空:没有可核对口径的愿景无法验证</span>
</el-form-item>
<el-form-item label="价值观总述" required>
<el-input
v-model="form.values_text"
type="textarea"
:rows="2"
maxlength="2000"
show-word-limit
placeholder="不可交易的底线,一句话总述"
/>
</el-form-item>
<el-form-item label="价值观条目" required>
<div class="value-editor">
<div v-for="(item, idx) in form.value_items" :key="idx" class="value-row">
<el-input v-model="item.name" maxlength="50" placeholder="名称(如 客户成功)" class="value-row-name" />
<el-input v-model="item.meaning" maxlength="300" placeholder="含义:决策冲突时如何裁决" class="value-row-meaning" />
<el-button
:icon="Delete"
text
type="danger"
:disabled="form.value_items.length <= 1"
title="删除该条"
@click="form.value_items.splice(idx, 1)"
/>
</div>
<el-button :icon="Plus" text type="primary" @click="addValueItem">新增条目</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false" :disabled="saving">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Aim, Delete, Edit, Lock, Opportunity, Plus, Star, Sunrise } from '@element-plus/icons-vue'
import MyDialog from '@/components/MyDialog.vue'
import { charterApi } from '@/api'
interface ValueItem { name: string; meaning: string }
const loading = ref(false)
const saving = ref(false)
const filled = ref(false)
const data = ref<any>(null)
const dialogVisible = ref(false)
const userRole = (() => {
try { return JSON.parse(localStorage.getItem('cma_user') || '{}').role || '' } catch { return '' }
})()
// 与后端 PUT 角色白名单保持一致(ceo / finance)
const canEdit = computed(() => ['ceo', 'finance'].includes(userRole))
const form = reactive({
mission: '',
vision: '',
vision_year: '',
vision_measurable: '',
values_text: '',
value_items: [{ name: '', meaning: '' }] as ValueItem[],
})
async function load() {
loading.value = true
try {
const res: any = await charterApi.get()
filled.value = !!res.filled
data.value = res.data
} catch (e) {
ElMessage.error('章程加载失败')
} finally {
loading.value = false
}
}
function openEdit() {
form.mission = data.value?.mission || ''
form.vision = data.value?.vision || ''
form.vision_year = data.value?.vision_year || ''
form.vision_measurable = data.value?.vision_measurable || ''
form.values_text = data.value?.values_text || ''
const items: ValueItem[] = (data.value?.value_items || []).map((i: ValueItem) => ({
name: i.name, meaning: i.meaning,
}))
form.value_items = items.length ? items : [{ name: '', meaning: '' }]
dialogVisible.value = true
}
function addValueItem() {
form.value_items.push({ name: '', meaning: '' })
}
function validate(): string | null {
if (!form.mission.trim()) return '使命不能为空'
if (!form.vision.trim()) return '愿景不能为空'
if (!/^\d{4}$/.test(form.vision_year.trim())) return '愿景年度须为 4 位年份(如 2030)'
if (!form.vision_measurable.trim()) return '可验证状态不能为空'
if (!form.values_text.trim()) return '价值观总述不能为空'
const items = form.value_items.filter(i => i.name.trim() || i.meaning.trim())
if (!items.length) return '价值观条目至少 1 条'
if (items.some(i => !i.name.trim() || !i.meaning.trim())) return '价值观条目的名称与含义都要填'
return null
}
async function save() {
const err = validate()
if (err) { ElMessage.warning(err); return }
saving.value = true
try {
// 只提交后端字段白名单内的字段(entity_id 由登录账套决定,不手写)
const payload = {
mission: form.mission.trim(),
vision: form.vision.trim(),
vision_year: form.vision_year.trim(),
vision_measurable: form.vision_measurable.trim(),
values_text: form.values_text.trim(),
value_items: form.value_items
.filter(i => i.name.trim() && i.meaning.trim())
.map(i => ({ name: i.name.trim(), meaning: i.meaning.trim() })),
}
const body: any = await charterApi.save(payload)
ElMessage.success('战略章程已保存')
dialogVisible.value = false
filled.value = true
data.value = body.data
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '保存失败,请稍后重试')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.charter-page { padding: var(--bh-space-xl, 20px); background: var(--bh-bg-main); min-height: 100%; }
.charter-head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--bh-space-lg, 16px); margin-bottom: var(--bh-space-xl, 20px); flex-wrap: wrap; }
.charter-title h2 { margin: 0 0 6px; font-size: 20px; color: var(--bh-text-primary); font-weight: 600; }
.charter-title p { margin: 0; font-size: 13px; color: var(--bh-text-secondary); }
.charter-actions { display: flex; align-items: center; gap: var(--bh-space-md, 12px); }
.charter-empty { background: var(--bh-bg-card); border: 1px dashed var(--bh-border); border-radius: var(--bh-radius-md, 8px); padding: 56px 24px; text-align: center; color: var(--bh-text-secondary); }
.charter-empty h3 { margin: 12px 0 6px; color: var(--bh-text-primary); font-size: 16px; }
.charter-empty p { margin: 0 0 18px; font-size: 13px; }
.charter-cards { display: grid; grid-template-columns: 1fr 1fr; gap: var(--bh-space-lg, 16px); }
.charter-card { background: var(--bh-bg-card); border-radius: var(--bh-radius-md, 8px); box-shadow: var(--bh-shadow-sm); padding: var(--bh-space-xl, 20px); border-left: 3px solid var(--bh-primary); }
.charter-card--wide { grid-column: 1 / -1; border-left-color: var(--bh-success); }
.card-label { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--bh-text-regular); }
.card-label span { font-weight: 400; font-size: 12px; color: var(--bh-text-placeholder); margin-left: 4px; letter-spacing: .3px; }
.year-tag { margin-left: auto; }
.card-text { margin: 12px 0 0; font-size: 15px; line-height: 1.75; color: var(--bh-text-primary); white-space: pre-wrap; word-break: break-word; }
.measurable { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--bh-border-light); display: flex; flex-direction: column; gap: 4px; }
.measurable-k { font-size: 12px; color: var(--bh-text-secondary); }
.measurable-v { font-size: 13px; color: var(--bh-text-regular); line-height: 1.6; word-break: break-word; }
.value-list { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.value-list li { display: flex; gap: 10px; align-items: baseline; padding: 8px 12px; background: var(--bh-bg-main); border-radius: var(--bh-radius-sm, 4px); }
.value-name { flex: 0 0 110px; font-size: 13px; font-weight: 600; color: var(--bh-text-primary); }
.value-meaning { font-size: 13px; color: var(--bh-text-regular); line-height: 1.6; }
.charter-meta { margin-top: var(--bh-space-lg, 16px); background: var(--bh-bg-card); border-radius: var(--bh-radius-md, 8px); box-shadow: var(--bh-shadow-sm); padding: var(--bh-space-lg, 16px) var(--bh-space-xl, 20px); display: flex; flex-wrap: wrap; gap: var(--bh-space-xl, 20px); }
.meta-item { display: flex; flex-direction: column; gap: 4px; min-width: 160px; }
.meta-k { font-size: 12px; color: var(--bh-text-secondary); }
.meta-v { font-size: 13px; color: var(--bh-text-primary); }
.charter-form :deep(.el-form-item) { margin-bottom: 16px; }
.form-hint { margin-left: 10px; font-size: 12px; color: var(--bh-text-placeholder); }
.year-input { width: 140px; }
.value-editor { width: 100%; display: flex; flex-direction: column; gap: 8px; }
.value-row { display: flex; gap: 8px; align-items: center; }
.value-row-name { width: 150px; flex: 0 0 150px; }
.value-row-meaning { flex: 1; }
</style>