ci/woodpecker/push/main Pipeline was successful
- 表 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
289 lines
13 KiB
Vue
289 lines
13 KiB
Vue
<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>
|