feat(projects): 新增项目、执行、任务等功能

This commit is contained in:
2026-05-09 11:30:34 +08:00
parent f4f43814b3
commit 824392b564
106 changed files with 13060 additions and 1049 deletions

View File

@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
@@ -27,7 +27,6 @@ interface StatusNavMeta {
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
const PRODUCT_OPTION_PAGE_SIZE = 200;
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams {
@@ -72,59 +71,6 @@ function formatDateTime(value?: string | null) {
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
async function fetchProductTotal(params: Api.Product.ProductSearchParams) {
const { error, data } = await fetchGetProductPage({
...params,
pageNo: 1,
pageSize: 1
});
if (error || !data) {
return 0;
}
return data.total;
}
async function fetchAllProducts() {
async function collect(pageNo: number, list: Api.Product.Product[]): Promise<Api.Product.Product[] | null> {
const { error, data } = await fetchGetProductPage({
pageNo,
pageSize: PRODUCT_OPTION_PAGE_SIZE
});
if (error || !data) {
return null;
}
const nextList = list.concat(data.list);
if (nextList.length >= data.total || data.list.length === 0) {
return nextList;
}
return collect(pageNo + 1, nextList);
}
return collect(1, []);
}
function createManagerOptions(products: Api.Product.Product[], users: Api.SystemManage.UserSimple[]) {
const managerIdSet = new Set(products.map(item => String(item.managerUserId)).filter(Boolean));
const userMap = new Map(users.map(item => [String(item.id), item]));
const options = Array.from(managerIdSet).map(managerUserId => {
return (
userMap.get(managerUserId) || {
id: managerUserId,
nickname: String(managerUserId)
}
);
});
return sortManagerOptions(options);
}
const statusNavMetas: StatusNavMeta[] = [
{
key: 'active',
@@ -166,15 +112,13 @@ const { routerPush } = useRouterPush();
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const statusCounts = ref<Record<Api.Product.ProductStatusCode, number>>({
const statusCounts = ref<Record<string, number>>({
active: 0,
archived: 0,
paused: 0,
abandoned: 0
});
const recentUpdatedCount = ref(0);
const managerLabelMap = computed(() => {
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
});
@@ -182,7 +126,7 @@ const managerLabelMap = computed(() => {
const statusItems = computed(() =>
statusNavMetas.map(item => ({
...item,
count: statusCounts.value[item.key]
count: statusCounts.value[item.key] ?? 0
}))
);
@@ -194,7 +138,7 @@ const overviewMetrics = computed(() => [
},
{
label: '当前启用',
value: statusCounts.value.active,
value: statusCounts.value.active ?? 0,
hint: '正在持续服务和维护的产品'
},
{
@@ -203,9 +147,9 @@ const overviewMetrics = computed(() => [
hint: '已加载的方向字典项数量'
},
{
label: '30天内更新',
value: recentUpdatedCount.value,
hint: '最近 30 天内发生过更新的产品'
label: '废弃产品',
value: statusCounts.value.abandoned ?? 0,
hint: '已明确停止建设的产品'
}
]);
@@ -312,44 +256,33 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
/>
)
}
]
],
immediate: false
});
async function loadManagerOptions() {
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
const { error, data: userList } = await fetchGetUserSimpleList();
const userSimpleList =
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
managerUserOptions.value = userSimpleList;
if (!allProducts) {
if (error || !userList) {
managerUserOptions.value = [];
managerFilterOptions.value = [];
return;
}
managerFilterOptions.value = createManagerOptions(allProducts, userSimpleList);
const userSimpleList = sortManagerOptions(userList);
managerUserOptions.value = userSimpleList;
managerFilterOptions.value = userSimpleList;
}
async function loadOverviewData() {
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
const start = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
const { error, data: overviewSummary } = await fetchGetProductOverviewSummary();
const [activeTotal, archivedTotal, pausedTotal, abandonedTotal, recentTotal] = await Promise.all([
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'active' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'archived' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'paused' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'abandoned' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, updateTime: [start, end] })
]);
if (error || !overviewSummary) {
statusCounts.value = {};
return;
}
statusCounts.value = {
active: activeTotal,
archived: archivedTotal,
paused: pausedTotal,
abandoned: abandonedTotal
};
recentUpdatedCount.value = recentTotal;
statusCounts.value = overviewSummary.statusCounts || {};
}
async function reloadProductTable(page = searchParams.pageNo ?? 1) {

View File

@@ -4,6 +4,7 @@ import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProductOperateDialog' });
@@ -166,14 +167,14 @@ watch(visible, async value => {
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
preset="sm"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
<ElInput
:model-value="model.code"
@@ -186,12 +187,12 @@ watch(visible, async value => {
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-model="model.directionCode"
@@ -201,7 +202,7 @@ watch(visible, async value => {
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem v-if="isEditMode">
<template #label>
<span class="business-form-label-with-tip">
@@ -225,9 +226,11 @@ watch(visible, async value => {
/>
</ElFormItem>
<ElFormItem v-else label="产品经理" prop="managerUserId">
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="请选择产品经理">
<ElOption v-for="item in managerUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择产品经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ProductSearch' });
@@ -9,7 +9,7 @@ interface Props {
managerOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
const props = defineProps<Props>();
interface Emits {
(e: 'reset'): void;
@@ -20,6 +20,32 @@ const emit = defineEmits<Emits>();
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
const fields = computed<SearchField[]>(() => [
{
key: 'keyword',
label: '关键词',
type: 'input',
placeholder: '产品名称 / 编号'
},
{
key: 'managerUserId',
label: '产品经理',
type: 'select',
options: props.managerOptions.map(item => ({
label: item.nickname,
value: item.id
})),
placeholder: '筛选产品经理'
},
{
key: 'directionCode',
label: '产品方向',
type: 'dict',
dictCode: RDMS_OBJECT_DIRECTION_DICT_CODE,
placeholder: '筛选产品方向'
}
]);
function reset() {
emit('reset');
}
@@ -30,30 +56,7 @@ function search() {
</script>
<template>
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="关键词">
<ElInput v-model="model.keyword" clearable placeholder="产品名称 / 编号" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="产品经理">
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="筛选产品经理">
<ElOption v-for="item in managerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="产品方向">
<DictSelect
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="筛选产品方向"
/>
</ElFormItem>
</ElCol>
</TableSearchPanel>
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
</template>
<style scoped></style>