devperf/frontend/src/components/roi/EventEntryModal.vue
zyc 5af612e3fd feat(roi): ROI 动态规则引擎 v1 + 业务体系归属
后端:
- 事件流模型(project_cost_events / project_revenue_events)+ launchedAt 截断
- 3 大业务体系归属(airhubs/airflow/aircore) + 项目类型(hw/sw) + identifier 自动生成
- AI 三件套推荐(category + bizSystem + projectType)
- 营收 mock API + 外部对接规范 + 资产摊销 cron
- 5 个 migration(0003 ROI 引擎 / 0004 driver factors / 0005 biz system)
- 单测 11/11 过

前端:
- 项目级 ROI 看板:4 KPI 卡片 + 折线图(周/月/年)+ 成本/产出事件流并排
- 全公司决策罗盘:3 大 ROI 指标 + 业务线堆叠 + 分类筛选 chip
- 项目列表 + 侧边栏:按产品线分组(可折叠 + localStorage 持久化)
- Admin: ROI 策略配置 + 项目映射 + 未映射收容

数据:
- 23 项目全部 AI 自动分类 + 自动 identifier(airhubs-hw-001 这种)
- launchedAt 按各项目首次 commit 时间设置

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:20:22 +08:00

118 lines
4.3 KiB
Vue

<script setup lang="ts">
import { ref, watch } from 'vue';
import {
NModal, NForm, NFormItem, NSelect, NDatePicker, NInputNumber, NInput, NButton, useMessage,
} from 'naive-ui';
import { createCostEvent, createRevenueEvent } from '@/api/roi';
const props = defineProps<{
show: boolean;
type: 'cost' | 'revenue';
projectId: string;
}>();
const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>();
const message = useMessage();
const saving = ref(false);
const dateTs = ref<number | null>(Date.now());
const form = ref({
costType: 'hardware_bom' as 'dev_hours' | 'hardware_bom' | 'service_fee' | 'amortization' | 'other',
revenueType: 'direct_revenue' as 'direct_revenue' | 'subscription' | 'saved_cost' | 'asset_value_add' | 'refund' | 'other',
amount: 0,
channel: '',
notes: '',
});
const COST_OPTIONS = [
{ label: '研发工时', value: 'dev_hours' },
{ label: '硬件 BOM', value: 'hardware_bom' },
{ label: '服务费/运维', value: 'service_fee' },
{ label: '摊销', value: 'amortization' },
{ label: '其他', value: 'other' },
];
const REVENUE_OPTIONS = [
{ label: '直接营收', value: 'direct_revenue' },
{ label: '订阅', value: 'subscription' },
{ label: '节约成本(效能工具)', value: 'saved_cost' },
{ label: '资产增值(护城河)', value: 'asset_value_add' },
{ label: '退款/冲账', value: 'refund' },
{ label: '其他', value: 'other' },
];
watch(() => props.show, (s) => {
if (s) {
dateTs.value = Date.now();
form.value = { costType: 'hardware_bom', revenueType: 'direct_revenue', amount: 0, channel: '', notes: '' };
}
});
function formatDate(ts: number | null): string {
if (ts === null) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function handleSave() {
if (dateTs.value === null) { message.warning('请选择日期'); return; }
if (props.type === 'cost' && form.value.amount < 0) { message.warning('成本金额必须 >= 0'); return; }
saving.value = true;
try {
const eventDate = formatDate(dateTs.value);
if (props.type === 'cost') {
await createCostEvent(props.projectId, {
eventDate,
costType: form.value.costType,
amount: form.value.amount,
notes: form.value.notes || undefined,
});
} else {
await createRevenueEvent(props.projectId, {
eventDate,
revenueType: form.value.revenueType,
amount: form.value.amount,
channel: form.value.channel || undefined,
notes: form.value.notes || undefined,
});
}
message.success('已保存');
emit('saved');
emit('update:show', false);
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.message || e.message));
} finally {
saving.value = false;
}
}
</script>
<template>
<NModal :show="show" preset="card" :title="type === 'cost' ? '录入成本事件' : '录入产出事件'" style="width:500px"
@update:show="(v: boolean) => emit('update:show', v)">
<NForm label-placement="top" size="medium">
<NFormItem label="发生日期">
<NDatePicker v-model:value="dateTs" type="date" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'cost'" label="成本类型">
<NSelect v-model:value="form.costType" :options="COST_OPTIONS" />
</NFormItem>
<NFormItem v-else label="产出类型">
<NSelect v-model:value="form.revenueType" :options="REVENUE_OPTIONS" />
</NFormItem>
<NFormItem :label="type === 'cost' ? '金额(元)' : '金额(元,退款用负数)'">
<NInputNumber v-model:value="form.amount" :min="type === 'cost' ? 0 : -1e8" :max="1e8" style="width:100%" />
</NFormItem>
<NFormItem v-if="type === 'revenue'" label="渠道(可选)">
<NInput v-model:value="form.channel" placeholder="alipay / wechat / stripe ..." />
</NFormItem>
<NFormItem label="备注(可选)">
<NInput v-model:value="form.notes" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" maxlength="500" />
</NFormItem>
<div style="display:flex;justify-content:flex-end;gap:8px">
<NButton @click="emit('update:show', false)">取消</NButton>
<NButton type="primary" :loading="saving" @click="handleSave">保存</NButton>
</div>
</NForm>
</NModal>
</template>