后端: - 事件流模型(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>
118 lines
4.3 KiB
Vue
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>
|