feat(projects): 1、站内信、通知功能完善;2、项目列表按会议需求重新开发
This commit is contained in:
@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { Box, DeleteFilled, VideoPause, VideoPlay } from '@element-plus/icons-vue';
|
||||
import { Box, DeleteFilled, Document, Menu, VideoPause, VideoPlay } from '@element-plus/icons-vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
||||
@@ -16,11 +16,10 @@ import ProductSearch from './modules/product-search.vue';
|
||||
|
||||
defineOptions({ name: 'ProductList' });
|
||||
|
||||
interface StatusNavMeta {
|
||||
key: Api.Product.ProductStatusCode;
|
||||
label: string;
|
||||
description: string;
|
||||
tone: 'teal' | 'slate' | 'amber' | 'rose';
|
||||
type StatusNavTone = 'sky' | 'teal' | 'slate' | 'amber' | 'rose';
|
||||
|
||||
interface StatusVisualMeta {
|
||||
tone: StatusNavTone;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
@@ -70,39 +69,20 @@ function formatDate(value?: string | null) {
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const statusNavMetas: StatusNavMeta[] = [
|
||||
{
|
||||
key: 'active',
|
||||
label: '启用产品',
|
||||
description: '当前正常服务中的产品',
|
||||
tone: 'teal',
|
||||
icon: VideoPlay
|
||||
},
|
||||
{
|
||||
key: 'archived',
|
||||
label: '归档产品',
|
||||
description: '已完成阶段目标的产品',
|
||||
tone: 'slate',
|
||||
icon: Box
|
||||
},
|
||||
{
|
||||
key: 'paused',
|
||||
label: '暂停产品',
|
||||
description: '阶段性暂停投入的产品',
|
||||
tone: 'amber',
|
||||
icon: VideoPause
|
||||
},
|
||||
{
|
||||
key: 'abandoned',
|
||||
label: '废弃产品',
|
||||
description: '已明确停止建设的产品',
|
||||
tone: 'rose',
|
||||
icon: DeleteFilled
|
||||
}
|
||||
];
|
||||
/** 状态视觉资产(icon/tone)是前端本地映射;状态名直接渲染后端 statusName,不做本地名称映射 */
|
||||
const STATUS_VISUALS: Record<string, StatusVisualMeta> = {
|
||||
active: { tone: 'teal', icon: VideoPlay },
|
||||
archived: { tone: 'slate', icon: Box },
|
||||
paused: { tone: 'amber', icon: VideoPause },
|
||||
abandoned: { tone: 'rose', icon: DeleteFilled }
|
||||
};
|
||||
|
||||
/** 状态机新增状态未配置视觉资产时的默认兜底:通用图标 + 中性色 */
|
||||
const DEFAULT_STATUS_VISUAL: StatusVisualMeta = { tone: 'slate', icon: Document };
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedStatus = ref<Api.Product.ProductStatusCode>('active');
|
||||
/** 当前选中导航键:状态编码(状态机动态下发)或 'all'(全部视图,分页接口不传 statusCode) */
|
||||
const selectedStatus = ref<string>('active');
|
||||
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const operateVisible = ref(false);
|
||||
@@ -111,23 +91,29 @@ const { routerPush } = useRouterPush();
|
||||
|
||||
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
|
||||
const statusCounts = ref<Record<string, number>>({
|
||||
active: 0,
|
||||
archived: 0,
|
||||
paused: 0,
|
||||
abandoned: 0
|
||||
});
|
||||
/** 状态看板项(overview-summary items,状态机动态下发,已按 sort 升序) */
|
||||
const statusBoardItems = ref<Api.Product.OverviewStatusItem[]>([]);
|
||||
/** "全部"口径总数(后端 total,前端不自行求和) */
|
||||
const statusBoardTotal = ref(0);
|
||||
|
||||
const managerLabelMap = computed(() => {
|
||||
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
const statusItems = computed(() =>
|
||||
statusNavMetas.map(item => ({
|
||||
...item,
|
||||
count: statusCounts.value[item.key] ?? 0
|
||||
}))
|
||||
);
|
||||
const statusItems = computed(() => [
|
||||
{ key: 'all', label: '全部产品', count: statusBoardTotal.value, tone: 'sky' as StatusNavTone, icon: Menu },
|
||||
...statusBoardItems.value.map(item => {
|
||||
const visual = STATUS_VISUALS[item.statusCode] ?? DEFAULT_STATUS_VISUAL;
|
||||
|
||||
return {
|
||||
key: item.statusCode,
|
||||
label: item.statusName,
|
||||
count: item.count,
|
||||
tone: visual.tone,
|
||||
icon: visual.icon
|
||||
};
|
||||
})
|
||||
]);
|
||||
|
||||
function getDirectionLabel(directionCode?: string | null) {
|
||||
return getDirectionDictLabel(directionCode, '--');
|
||||
@@ -145,7 +131,7 @@ function createRequestParams(): Api.Product.ProductSearchParams {
|
||||
return {
|
||||
...searchParams,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
statusCode: selectedStatus.value
|
||||
statusCode: selectedStatus.value === 'all' ? undefined : selectedStatus.value
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,12 +219,8 @@ async function loadManagerOptions() {
|
||||
async function loadOverviewData() {
|
||||
const { error, data: overviewSummary } = await fetchGetProductOverviewSummary();
|
||||
|
||||
if (error || !overviewSummary) {
|
||||
statusCounts.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
statusCounts.value = overviewSummary.statusCounts || {};
|
||||
statusBoardItems.value = error || !overviewSummary ? [] : overviewSummary.items;
|
||||
statusBoardTotal.value = error || !overviewSummary ? 0 : overviewSummary.total;
|
||||
}
|
||||
|
||||
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
|
||||
@@ -263,7 +245,7 @@ async function handleResetSearch() {
|
||||
await reloadProductTable(1);
|
||||
}
|
||||
|
||||
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
|
||||
async function handleStatusChange(status: string) {
|
||||
selectedStatus.value = status;
|
||||
await Promise.all([loadOverviewData(), reloadProductTable(1)]);
|
||||
}
|
||||
@@ -302,7 +284,7 @@ onMounted(async () => {
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[396px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<ElCard class="product-overview-card card-wrapper">
|
||||
<ElCard class="product-overview-card card-wrapper xl:flex-1">
|
||||
<div class="product-status-panel__list">
|
||||
<button
|
||||
v-for="item in statusItems"
|
||||
@@ -323,7 +305,6 @@ onMounted(async () => {
|
||||
<strong>{{ item.label }}</strong>
|
||||
<em>{{ item.count }}</em>
|
||||
</div>
|
||||
<p class="product-status-item__desc">{{ item.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -462,7 +443,6 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-status-item__top strong {
|
||||
@@ -478,10 +458,9 @@ onMounted(async () => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-status-item__desc {
|
||||
color: rgb(100 116 139 / 94%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
.product-status-item--sky .product-status-item__icon {
|
||||
background-color: rgb(240 249 255 / 96%);
|
||||
color: rgb(2 132 199 / 96%);
|
||||
}
|
||||
|
||||
.product-status-item--teal .product-status-item__icon {
|
||||
|
||||
@@ -16,19 +16,21 @@ export const productStatusActionRecord: Record<Api.Product.ProductStatusActionCo
|
||||
abandon: '废弃产品'
|
||||
};
|
||||
|
||||
export function getProductStatusLabel(status: Api.Product.ProductStatusCode) {
|
||||
return productStatusRecord[status];
|
||||
/** 状态编码来自状态机动态下发,未配置的新状态回退编码本身(展示名优先用后端 statusName) */
|
||||
export function getProductStatusLabel(status: string) {
|
||||
return (productStatusRecord as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
|
||||
export function getProductStatusTagType(status: Api.Product.ProductStatusCode): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<Api.Product.ProductStatusCode, UI.ThemeColor> = {
|
||||
/** 根据产品状态返回对应的 Tag 类型;未配置的新状态回退 info */
|
||||
export function getProductStatusTagType(status: string): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<string, UI.ThemeColor> = {
|
||||
active: 'success',
|
||||
paused: 'warning',
|
||||
archived: 'info',
|
||||
abandoned: 'danger'
|
||||
};
|
||||
|
||||
return statusTagTypeMap[status];
|
||||
return statusTagTypeMap[status] ?? 'info';
|
||||
}
|
||||
|
||||
export function isProductEditable(status: Api.Product.ProductStatusCode) {
|
||||
|
||||
@@ -1,288 +1,200 @@
|
||||
<script setup lang="tsx">
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElButton, ElProgress, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { ElButton } from 'element-plus';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProjectOverviewSummary, fetchGetProjectPage, fetchGetUserSimpleList } from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import {
|
||||
fetchGetProductPage,
|
||||
fetchGetProjectGroupPage,
|
||||
fetchGetProjectOverviewSummary,
|
||||
fetchGetProjectPage,
|
||||
fetchGetUserSimpleList
|
||||
} from '@/service/api';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import { getProjectStatusLabel, getProjectStatusTagType } from '../shared/project-master-data';
|
||||
import ProjectGroupedTable from './modules/project-grouped-table.vue';
|
||||
import ProjectOperateDialog from './modules/project-operate-dialog.vue';
|
||||
import ProjectOverviewCard from './modules/project-overview-card.vue';
|
||||
import ProjectSearch from './modules/project-search.vue';
|
||||
import ProjectStatusRail, { type ProjectListNavKey } from './modules/project-status-rail.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectList' });
|
||||
|
||||
type ProjectPageResponse = Awaited<ReturnType<typeof fetchGetProjectPage>>;
|
||||
|
||||
const PROJECT_ENTRY_ROUTE_PATH = '/project/list';
|
||||
/** 组内默认直出条数(设计决策 N=5,超出收纳为"还有 X 个";与后端 group-page topN 对齐) */
|
||||
const GROUP_TOP_N = 5;
|
||||
/** 每页产品组数(设计决策 M=10,按产品分页) */
|
||||
const GROUP_PAGE_SIZE = 10;
|
||||
/** 展开剩余单次拉取上限(内网单产品项目量级内安全) */
|
||||
const GROUP_REMAINING_PAGE_SIZE = 200;
|
||||
|
||||
function getInitSearchParams(): Api.Project.ProjectSearchParams {
|
||||
function getInitSearchParams(): Api.Project.ProjectGroupSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
pageSize: GROUP_PAGE_SIZE,
|
||||
keyword: '',
|
||||
directionCode: undefined,
|
||||
projectType: undefined,
|
||||
productId: undefined,
|
||||
managerUserId: undefined,
|
||||
projectType: undefined,
|
||||
statusCode: undefined,
|
||||
updateTime: undefined
|
||||
orphanOnly: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformProjectPage(response: ProjectPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
|
||||
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
function createEmptyGroupPage(): Api.Project.ProjectGroupPageResult {
|
||||
return { total: 0, projectTotal: 0, directionCount: 0, orphanTotal: 0, list: [] };
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedStatus = ref<Api.Project.ProjectStatusCode>('active');
|
||||
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const navKey = ref<ProjectListNavKey>('active');
|
||||
const groupPage = ref<Api.Project.ProjectGroupPageResult>(createEmptyGroupPage());
|
||||
const loading = ref(false);
|
||||
const allCollapsed = ref(false);
|
||||
/** 状态看板项(overview-summary items,状态机动态下发),左栏与"全部"口径派生均以此为源 */
|
||||
const statusBoardItems = ref<Api.Project.OverviewStatusItem[]>([]);
|
||||
/** "全部"口径总数(后端 total,前端不自行求和) */
|
||||
const statusBoardTotal = ref(0);
|
||||
/** 游离项目数:取 overview-summary 的 orphanCount(全口径常驻),左栏游离入口据此显隐 */
|
||||
const orphanCount = ref(0);
|
||||
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const productOptions = ref<Array<{ id: string; name: string }>>([]);
|
||||
const operateVisible = ref(false);
|
||||
const editingRow = ref<Api.Project.Project | null>(null);
|
||||
const presetProduct = ref<{ id: string; name: string } | null>(null);
|
||||
const { routerPush } = useRouterPush();
|
||||
|
||||
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
||||
|
||||
const statusCounts = ref<Record<string, number>>({
|
||||
pending: 0,
|
||||
active: 0,
|
||||
paused: 0,
|
||||
completed: 0,
|
||||
cancelled: 0,
|
||||
archived: 0
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'pending', label: '待开始' },
|
||||
{ value: 'active', label: '进行中' },
|
||||
{ value: 'paused', label: '已暂停' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'cancelled', label: '作废项目' },
|
||||
{ value: 'archived', label: '归档项目' }
|
||||
]);
|
||||
|
||||
const managerLabelMap = computed(() => {
|
||||
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
function getDirectionLabel(directionCode?: string | null) {
|
||||
return getDirectionDictLabel(directionCode, '--');
|
||||
}
|
||||
const showDirectionLayer = computed(() => groupPage.value.directionCount >= 2);
|
||||
|
||||
function getProjectTypeLabelByCode(projectType?: string | null) {
|
||||
return getProjectTypeLabel(projectType, '--');
|
||||
}
|
||||
/** "全部"视图状态编码:由状态看板项按 includeInAll 派生(当前口径无排除项),用于展开剩余时 statusCodes 多值过滤 */
|
||||
const allViewStatusCodes = computed(() =>
|
||||
statusBoardItems.value.filter(item => item.includeInAll).map(item => item.statusCode)
|
||||
);
|
||||
|
||||
function getManagerLabel(managerUserId?: string | null) {
|
||||
if (!managerUserId) {
|
||||
return '--';
|
||||
const headerMeta = computed(() => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (showDirectionLayer.value) {
|
||||
parts.push(`跨 ${groupPage.value.directionCount} 个方向`);
|
||||
}
|
||||
|
||||
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||
}
|
||||
parts.push(`${groupPage.value.total} 个分组`);
|
||||
parts.push(`${groupPage.value.projectTotal} 个项目`);
|
||||
|
||||
return parts.join(' · ');
|
||||
});
|
||||
|
||||
function createRequestParams(): Api.Project.ProjectGroupSearchParams {
|
||||
const isStatusNav = navKey.value !== 'all' && navKey.value !== 'orphan';
|
||||
|
||||
function createRequestParams(): Api.Project.ProjectSearchParams {
|
||||
return {
|
||||
...searchParams,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
statusCode: selectedStatus.value
|
||||
topN: GROUP_TOP_N,
|
||||
statusCode: isStatusNav ? navKey.value : undefined,
|
||||
orphanOnly: navKey.value === 'orphan' ? true : undefined
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
ProjectPageResponse,
|
||||
Api.Project.Project
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetProjectPage(createRequestParams()),
|
||||
transform: response => transformProjectPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{
|
||||
prop: 'projectName',
|
||||
label: '项目名称',
|
||||
minWidth: 220,
|
||||
formatter: row => (
|
||||
<ElButton link type="primary" class="project-name-link" onClick={() => enterProjectContext(row)}>
|
||||
{row.projectName}
|
||||
</ElButton>
|
||||
)
|
||||
},
|
||||
{ prop: 'projectCode', label: '项目编码', minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'directionCode',
|
||||
label: '项目方向',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getDirectionLabel(row.directionCode)
|
||||
},
|
||||
{
|
||||
prop: 'projectType',
|
||||
label: '项目类型',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getProjectTypeLabelByCode(row.projectType)
|
||||
},
|
||||
{
|
||||
prop: 'managerUserId',
|
||||
label: '项目经理',
|
||||
minWidth: 120,
|
||||
formatter: row => getManagerLabel(row.managerUserId)
|
||||
},
|
||||
{
|
||||
prop: 'progressRate',
|
||||
label: '进度',
|
||||
width: 160,
|
||||
formatter: row => {
|
||||
const percentage = row.progressRate ?? 0;
|
||||
return (
|
||||
<div style="padding: 0 8px;">
|
||||
<ElProgress
|
||||
percentage={percentage}
|
||||
status={percentage >= 100 ? 'success' : undefined}
|
||||
stroke-width={18}
|
||||
text-inside
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={getProjectStatusTagType(row.statusCode)}>{getProjectStatusLabel(row.statusCode)}</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'updateTime',
|
||||
label: '最近更新',
|
||||
width: 170,
|
||||
align: 'center',
|
||||
formatter: row => formatDate(row.updateTime)
|
||||
}
|
||||
],
|
||||
immediate: false
|
||||
});
|
||||
async function loadGroupPage(page = searchParams.pageNo ?? 1) {
|
||||
searchParams.pageNo = page;
|
||||
loading.value = true;
|
||||
|
||||
async function loadManagerOptions() {
|
||||
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||
const { error, data } = await fetchGetProjectGroupPage(createRequestParams());
|
||||
|
||||
if (error || !userList) {
|
||||
managerUserOptions.value = [];
|
||||
managerFilterOptions.value = [];
|
||||
return;
|
||||
loading.value = false;
|
||||
groupPage.value = !error && data ? data : createEmptyGroupPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开某组剩余项目:后端 group-page 每组仅返前 topN 条,剩余按当前视图状态口径 +
|
||||
* 产品/游离维度走 page 接口拉该组全量,回灌给分组表格组件(注入到其 fetchMore)。
|
||||
*/
|
||||
async function loadGroupRemaining(group: Api.Project.ProjectGroup): Promise<Api.Project.Project[]> {
|
||||
const isStatusNav = navKey.value !== 'all' && navKey.value !== 'orphan';
|
||||
|
||||
const { error, data } = await fetchGetProjectPage({
|
||||
pageNo: 1,
|
||||
pageSize: GROUP_REMAINING_PAGE_SIZE,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
projectType: searchParams.projectType || undefined,
|
||||
productId: group.orphan ? undefined : (group.productId ?? undefined),
|
||||
orphanOnly: group.orphan ? true : undefined,
|
||||
statusCode: isStatusNav ? navKey.value : undefined,
|
||||
statusCodes: isStatusNav ? undefined : allViewStatusCodes.value
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error('加载该组剩余项目失败');
|
||||
}
|
||||
|
||||
const userSimpleList = sortManagerOptions(userList);
|
||||
managerUserOptions.value = userSimpleList;
|
||||
managerFilterOptions.value = userSimpleList;
|
||||
return data.list;
|
||||
}
|
||||
|
||||
async function loadOverviewData() {
|
||||
const { error, data: overviewSummary } = await fetchGetProjectOverviewSummary();
|
||||
const { error, data } = await fetchGetProjectOverviewSummary();
|
||||
|
||||
if (error || !overviewSummary) {
|
||||
statusCounts.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
statusCounts.value = overviewSummary.statusCounts || {};
|
||||
statusBoardItems.value = error || !data ? [] : data.items;
|
||||
statusBoardTotal.value = error || !data ? 0 : data.total;
|
||||
orphanCount.value = error || !data ? 0 : (data.orphanCount ?? 0);
|
||||
}
|
||||
|
||||
async function reloadProjectTable(page = searchParams.pageNo ?? 1) {
|
||||
await getDataByPage(page);
|
||||
async function loadManagerOptions() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
|
||||
managerUserOptions.value =
|
||||
error || !data ? [] : data.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
|
||||
}
|
||||
|
||||
async function loadProductOptions() {
|
||||
const { error, data } = await fetchGetProductPage({ pageNo: 1, pageSize: 200 });
|
||||
|
||||
productOptions.value =
|
||||
error || !data
|
||||
? []
|
||||
: data.list.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name || item.code || item.id
|
||||
}));
|
||||
}
|
||||
|
||||
async function refreshPageData(page = searchParams.pageNo ?? 1) {
|
||||
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProjectTable(page)]);
|
||||
await Promise.all([loadManagerOptions(), loadProductOptions(), loadOverviewData(), loadGroupPage(page)]);
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await reloadProjectTable(1);
|
||||
await loadGroupPage(1);
|
||||
}
|
||||
|
||||
async function handleResetSearch() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize
|
||||
});
|
||||
|
||||
await reloadProjectTable(1);
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
await loadGroupPage(1);
|
||||
}
|
||||
|
||||
async function handleStatusChange(status: Api.Project.ProjectStatusCode) {
|
||||
selectedStatus.value = status;
|
||||
await Promise.all([loadOverviewData(), reloadProjectTable(1)]);
|
||||
async function handleNavChange(key: ProjectListNavKey) {
|
||||
navKey.value = key;
|
||||
allCollapsed.value = false;
|
||||
await Promise.all([loadOverviewData(), loadGroupPage(1)]);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingRow.value = null;
|
||||
function openCreate(group?: Api.Project.ProjectGroup) {
|
||||
presetProduct.value = group?.productId ? { id: group.productId, name: group.productName } : null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
async function enterProjectContext(row: Api.Project.Project) {
|
||||
async function enterProjectContext(project: Api.Project.Project) {
|
||||
await routerPush({
|
||||
path: PROJECT_ENTRY_ROUTE_PATH,
|
||||
query: {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: row.id
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: project.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleProjectSubmitted(projectId?: string) {
|
||||
const isEditing = Boolean(projectId && editingRow.value?.id === projectId);
|
||||
|
||||
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
|
||||
|
||||
if (isEditing) {
|
||||
editingRow.value = null;
|
||||
}
|
||||
async function handleProjectSubmitted() {
|
||||
await Promise.all([loadOverviewData(), loadGroupPage(searchParams.pageNo ?? 1)]);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshPageData();
|
||||
await refreshPageData(1);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -290,18 +202,21 @@ onMounted(async () => {
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[396px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<ProjectOverviewCard
|
||||
:status-counts="statusCounts"
|
||||
:selected-status="selectedStatus"
|
||||
@status-change="handleStatusChange"
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0 xl:overflow-auto">
|
||||
<ProjectStatusRail
|
||||
class="xl:flex-1"
|
||||
:items="statusBoardItems"
|
||||
:total="statusBoardTotal"
|
||||
:orphan-count="orphanCount"
|
||||
:selected="navKey"
|
||||
@change="handleNavChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<ProjectSearch
|
||||
v-model:model="searchParams"
|
||||
:manager-options="managerFilterOptions"
|
||||
:product-options="productOptions"
|
||||
@reset="handleResetSearch"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
@@ -309,52 +224,50 @@ onMounted(async () => {
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="project-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">项目列表</p>
|
||||
<ElTag effect="plain" :type="getProjectStatusTagType(selectedStatus)">
|
||||
{{
|
||||
statusOptions.find(item => item.value === selectedStatus)?.label ||
|
||||
getProjectStatusLabel(selectedStatus)
|
||||
}}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<div class="min-w-0 flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">项目列表</p>
|
||||
<span class="project-card-header__meta">{{ headerMeta }}</span>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-8px">
|
||||
<ElButton plain :disabled="!groupPage.list.length" @click="allCollapsed = !allCollapsed">
|
||||
<template #icon>
|
||||
<icon-ic-round-unfold-more v-if="allCollapsed" class="text-icon" />
|
||||
<icon-ic-round-unfold-less v-else class="text-icon" />
|
||||
</template>
|
||||
{{ allCollapsed ? '展开全部' : '折叠全部' }}
|
||||
</ElButton>
|
||||
<ElButton plain type="primary" @click="openCreate()">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增项目
|
||||
</ElButton>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="refreshPageData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openCreate">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
|
||||
<template #empty>
|
||||
<ElEmpty description="当前筛选条件下暂无项目" />
|
||||
</template>
|
||||
</ElTable>
|
||||
<ProjectGroupedTable
|
||||
:groups="groupPage.list"
|
||||
:loading="loading"
|
||||
:all-view="navKey === 'all'"
|
||||
:show-direction-layer="showDirectionLayer"
|
||||
:top-n="GROUP_TOP_N"
|
||||
:all-collapsed="allCollapsed"
|
||||
:manager-label-map="managerLabelMap"
|
||||
:fetch-more="loadGroupRemaining"
|
||||
@enter="enterProjectContext"
|
||||
@create="openCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
v-if="groupPage.total > GROUP_PAGE_SIZE"
|
||||
layout="total,prev,pager,next"
|
||||
:total="groupPage.total"
|
||||
:page-size="GROUP_PAGE_SIZE"
|
||||
:current-page="searchParams.pageNo ?? 1"
|
||||
@current-change="loadGroupPage"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
@@ -363,7 +276,7 @@ onMounted(async () => {
|
||||
<ProjectOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:manager-user-options="managerUserOptions"
|
||||
:row-data="editingRow"
|
||||
:preset-product="presetProduct"
|
||||
@submitted="handleProjectSubmitted"
|
||||
/>
|
||||
</div>
|
||||
@@ -377,8 +290,9 @@ onMounted(async () => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-name-link {
|
||||
padding: 0;
|
||||
.project-card-header__meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
@@ -165,17 +165,26 @@ async function loadProductOptions() {
|
||||
}));
|
||||
}
|
||||
|
||||
function onProductChange(newProductId: string | null) {
|
||||
if (!newProductId) {
|
||||
// 所属产品变化(手动选择 / 弹窗预填 / 重开重置)时同步项目方向:产品方向是唯一权威来源。
|
||||
// 不放 onMounted:弹窗复用同一表单实例,重开时模型已重置而 mounted 钩子不会再跑,会留下"方向只读 + 空值"的校验死局。
|
||||
watch([() => model.value.productId, productOptions], ([productId]) => {
|
||||
if (!productId || !productOptions.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const product = productOptions.value.find(p => p.id === newProductId);
|
||||
const product = productOptions.value.find(p => p.id === productId);
|
||||
|
||||
if (product) {
|
||||
model.value.directionCode = product.directionCode;
|
||||
if (model.value.directionCode !== product.directionCode) {
|
||||
model.value.directionCode = product.directionCode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 产品选项未命中(加载失败/已被删)时解除关联,避免方向锁死在空值
|
||||
model.value.productId = null;
|
||||
window.$message?.warning('未找到所选产品,请手动选择所属产品');
|
||||
});
|
||||
|
||||
async function runValidate(): Promise<boolean> {
|
||||
try {
|
||||
@@ -186,9 +195,15 @@ async function runValidate(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProductOptions);
|
||||
function clearValidate() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
defineExpose({ validate: runValidate });
|
||||
onMounted(async () => {
|
||||
await loadProductOptions();
|
||||
});
|
||||
|
||||
defineExpose({ validate: runValidate, clearValidate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -211,7 +226,6 @@ defineExpose({ validate: runValidate });
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择所属产品(可选),选择后将锁定项目方向"
|
||||
@change="onProductChange"
|
||||
>
|
||||
<ElOption v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
|
||||
736
src/views/project/list/modules/project-grouped-table.vue
Normal file
736
src/views/project/list/modules/project-grouped-table.vue
Normal file
@@ -0,0 +1,736 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElButton, ElEmpty, ElProgress, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { ArrowDown, Collection, QuestionFilled } from '@element-plus/icons-vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { getProjectStatusLabel, getProjectStatusTagType } from '../../shared/project-master-data';
|
||||
|
||||
defineOptions({ name: 'ProjectGroupedTable' });
|
||||
|
||||
interface Props {
|
||||
groups: Api.Project.ProjectGroup[];
|
||||
loading?: boolean;
|
||||
/** 当前是否"全部"视图(决定暂无项目占位是否渲染) */
|
||||
allView: boolean;
|
||||
/** 是否渲染方向小节层(可见产品方向数 ≥2) */
|
||||
showDirectionLayer: boolean;
|
||||
/** 组内默认直出条数(超出收纳) */
|
||||
topN: number;
|
||||
/** 折叠全部开关(true = 全部产品组折叠;方向小节保持展开) */
|
||||
allCollapsed: boolean;
|
||||
/** userId -> 昵称,项目经理列兜底回显 */
|
||||
managerLabelMap: Map<string, string>;
|
||||
/**
|
||||
* 拉取某组「展开剩余」的完整项目列表(后端 group-page 仅返前 topN 条)。
|
||||
* 由父组件注入:内部按 productId/orphanOnly + 当前状态口径调 page 接口并归一,返回该组全量项目(含前 N 条)。
|
||||
*/
|
||||
fetchMore: (group: Api.Project.ProjectGroup) => Promise<Api.Project.Project[]>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { loading: false });
|
||||
|
||||
interface Emits {
|
||||
(e: 'enter', project: Api.Project.Project): void;
|
||||
(e: 'create', group: Api.Project.ProjectGroup): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const { getLabel: getTypeLabel, dictOptions: typeOptions } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
||||
|
||||
/** 方向小节:把相邻同方向的产品组切成段;游离组单独成段(不渲染方向行) */
|
||||
interface DirectionSection {
|
||||
key: string;
|
||||
directionCode: string;
|
||||
orphan: boolean;
|
||||
groups: Api.Project.ProjectGroup[];
|
||||
}
|
||||
|
||||
const sections = computed<DirectionSection[]>(() => {
|
||||
const list: DirectionSection[] = [];
|
||||
|
||||
for (const group of props.groups) {
|
||||
const last = list[list.length - 1];
|
||||
|
||||
if (last && !last.orphan && !group.orphan && last.directionCode === group.directionCode) {
|
||||
last.groups.push(group);
|
||||
} else {
|
||||
list.push({
|
||||
key: group.orphan ? 'orphan' : `dir-${group.directionCode}`,
|
||||
directionCode: group.orphan ? '' : group.directionCode,
|
||||
orphan: group.orphan,
|
||||
groups: [group]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
function groupKey(group: Api.Project.ProjectGroup) {
|
||||
return group.productId ?? 'orphan';
|
||||
}
|
||||
|
||||
function sectionProjectCount(section: DirectionSection) {
|
||||
return section.groups.reduce((sum, group) => sum + group.projectTotal, 0);
|
||||
}
|
||||
|
||||
// === 折叠 / 展开内部态(数据刷新即重置,默认全部展开) ===
|
||||
const collapsedDirections = ref(new Set<string>());
|
||||
const collapsedProducts = ref(new Set<string>());
|
||||
const revealedProducts = ref(new Set<string>());
|
||||
/** 已拉取的「展开剩余」完整列表缓存:groupKey -> 全量项目(避免重复请求) */
|
||||
const expandedProjects = ref(new Map<string, Api.Project.Project[]>());
|
||||
/** 正在拉取「展开剩余」的 groupKey(收纳行 loading 态) */
|
||||
const expandingKeys = ref(new Set<string>());
|
||||
|
||||
watch(
|
||||
() => props.groups,
|
||||
() => {
|
||||
collapsedDirections.value = new Set();
|
||||
collapsedProducts.value = props.allCollapsed ? new Set(props.groups.map(groupKey)) : new Set();
|
||||
revealedProducts.value = new Set();
|
||||
expandedProjects.value = new Map();
|
||||
expandingKeys.value = new Set();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.allCollapsed,
|
||||
value => {
|
||||
collapsedProducts.value = value ? new Set(props.groups.map(groupKey)) : new Set();
|
||||
}
|
||||
);
|
||||
|
||||
function toggleDirection(key: string) {
|
||||
const next = new Set(collapsedDirections.value);
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
|
||||
collapsedDirections.value = next;
|
||||
}
|
||||
|
||||
function toggleProduct(key: string) {
|
||||
const next = new Set(collapsedProducts.value);
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
|
||||
collapsedProducts.value = next;
|
||||
}
|
||||
|
||||
async function toggleReveal(group: Api.Project.ProjectGroup) {
|
||||
const key = groupKey(group);
|
||||
|
||||
// 正在拉取剩余时忽略重复点击(page 是 GET、不走全局去重,防双击双发请求)
|
||||
if (expandingKeys.value.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 已展开 → 收起(保留已拉取缓存,避免再次请求)
|
||||
if (revealedProducts.value.has(key)) {
|
||||
const next = new Set(revealedProducts.value);
|
||||
next.delete(key);
|
||||
revealedProducts.value = next;
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次展开且后端只返了前 N 条 → 异步拉该组完整列表
|
||||
const needFetch = group.projects.length < group.projectTotal && !expandedProjects.value.has(key);
|
||||
|
||||
if (needFetch) {
|
||||
expandingKeys.value = new Set(expandingKeys.value).add(key);
|
||||
|
||||
try {
|
||||
const full = await props.fetchMore(group);
|
||||
expandedProjects.value = new Map(expandedProjects.value).set(key, full);
|
||||
} catch {
|
||||
window.$message?.error('展开失败,请重试');
|
||||
const failNext = new Set(expandingKeys.value);
|
||||
failNext.delete(key);
|
||||
expandingKeys.value = failNext;
|
||||
return;
|
||||
}
|
||||
|
||||
const doneNext = new Set(expandingKeys.value);
|
||||
doneNext.delete(key);
|
||||
expandingKeys.value = doneNext;
|
||||
}
|
||||
|
||||
revealedProducts.value = new Set(revealedProducts.value).add(key);
|
||||
}
|
||||
|
||||
// === 组内 Top-N 收纳(所有状态视图生效) ===
|
||||
function visibleProjects(group: Api.Project.ProjectGroup) {
|
||||
const key = groupKey(group);
|
||||
|
||||
if (revealedProducts.value.has(key)) {
|
||||
// 拉过剩余用完整缓存;若组内总数本就 ≤ topN(无需拉取)则直接用 group.projects
|
||||
return expandedProjects.value.get(key) ?? group.projects;
|
||||
}
|
||||
|
||||
return group.projects.slice(0, props.topN);
|
||||
}
|
||||
|
||||
/**
|
||||
* group.projects 为后端 group-page 返回的前 topN 条,projectTotal 为组内全量计数。
|
||||
* 展开剩余通过注入的 props.fetchMore 异步拉取该组完整列表并缓存(见 toggleReveal)。
|
||||
*/
|
||||
function hiddenCount(group: Api.Project.ProjectGroup) {
|
||||
return Math.max(group.projectTotal - props.topN, 0);
|
||||
}
|
||||
|
||||
// === 扁平行模型:分组结构铺平后交给 ElTable 渲染(非项目行整行合并单元格) ===
|
||||
type FlatRowType = 'dir' | 'product' | 'hint-empty' | 'project' | 'more';
|
||||
|
||||
interface FlatRow {
|
||||
rowType: FlatRowType;
|
||||
key: string;
|
||||
section?: DirectionSection;
|
||||
group?: Api.Project.ProjectGroup;
|
||||
project?: Api.Project.Project;
|
||||
}
|
||||
|
||||
const COLUMN_COUNT = 7;
|
||||
|
||||
/** 单个产品组铺平为行:产品行 +(未折叠时)占位/项目/收纳行 */
|
||||
function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
|
||||
const key = groupKey(group);
|
||||
const rows: FlatRow[] = [{ rowType: 'product', key: `prod-${key}`, group }];
|
||||
|
||||
if (collapsedProducts.value.has(key)) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (props.allView && group.projectTotal === 0) {
|
||||
rows.push({ rowType: 'hint-empty', key: `hint-empty-${key}`, group });
|
||||
}
|
||||
|
||||
for (const project of visibleProjects(group)) {
|
||||
rows.push({ rowType: 'project', key: `proj-${project.id}`, group, project });
|
||||
}
|
||||
|
||||
if (hiddenCount(group) > 0) {
|
||||
rows.push({ rowType: 'more', key: `more-${key}`, group });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const flatRows = computed<FlatRow[]>(() => {
|
||||
const rows: FlatRow[] = [];
|
||||
|
||||
for (const section of sections.value) {
|
||||
if (props.showDirectionLayer && !section.orphan) {
|
||||
rows.push({ rowType: 'dir', key: `dir-${section.key}`, section });
|
||||
}
|
||||
|
||||
if (!collapsedDirections.value.has(section.key)) {
|
||||
for (const group of section.groups) {
|
||||
rows.push(...buildGroupRows(group));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
function getRowKey(row: FlatRow) {
|
||||
return row.key;
|
||||
}
|
||||
|
||||
function spanMethod({ row, columnIndex }: { row: FlatRow; columnIndex: number }) {
|
||||
if (row.rowType === 'project') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return columnIndex === 0 ? { rowspan: 1, colspan: COLUMN_COUNT } : { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
|
||||
function rowClassName({ row }: { row: FlatRow }) {
|
||||
if (row.rowType === 'dir') {
|
||||
const collapsed = row.section && collapsedDirections.value.has(row.section.key);
|
||||
return `pg-dir-row${collapsed ? ' is-collapsed' : ''}`;
|
||||
}
|
||||
|
||||
if (row.rowType === 'product') {
|
||||
const collapsed = row.group && collapsedProducts.value.has(groupKey(row.group));
|
||||
return `pg-prod-row${collapsed ? ' is-collapsed' : ''}`;
|
||||
}
|
||||
|
||||
if (row.rowType === 'hint-empty') {
|
||||
return 'pg-hint-row';
|
||||
}
|
||||
|
||||
if (row.rowType === 'more') {
|
||||
return 'pg-more-row';
|
||||
}
|
||||
|
||||
return 'pg-proj-row';
|
||||
}
|
||||
|
||||
function handleRowClick(row: FlatRow) {
|
||||
if (row.rowType === 'dir' && row.section) {
|
||||
toggleDirection(row.section.key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.rowType === 'product' && row.group) {
|
||||
toggleProduct(groupKey(row.group));
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.rowType === 'more' && row.group) {
|
||||
toggleReveal(row.group);
|
||||
}
|
||||
}
|
||||
|
||||
// === 回显 ===
|
||||
interface TypeBadge {
|
||||
value: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function groupTypeBadges(group: Api.Project.ProjectGroup): TypeBadge[] {
|
||||
return (typeOptions.value ?? [])
|
||||
.filter(option => (group.typeCounts[option.value] ?? 0) > 0)
|
||||
.map(option => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
count: group.typeCounts[option.value]
|
||||
}));
|
||||
}
|
||||
|
||||
function productManagerLabel(group: Api.Project.ProjectGroup) {
|
||||
if (group.managerUserNickname) {
|
||||
return group.managerUserNickname;
|
||||
}
|
||||
|
||||
if (!group.managerUserId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return props.managerLabelMap.get(group.managerUserId) || '';
|
||||
}
|
||||
|
||||
function productMetaLabel(group: Api.Project.ProjectGroup) {
|
||||
const manager = productManagerLabel(group);
|
||||
|
||||
return [group.productCode, manager ? `经理 ${manager}` : ''].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function projectManagerLabel(project: Api.Project.Project) {
|
||||
return project.managerUserNickname || props.managerLabelMap.get(project.managerUserId) || '--';
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/** 计划周期回显:两端完整日期,缺失端以 ? 占位 */
|
||||
function formatPlannedRange(project: Api.Project.Project) {
|
||||
const start = project.plannedStartDate ? dayjs(project.plannedStartDate).format('YYYY-MM-DD') : '';
|
||||
const end = project.plannedEndDate ? dayjs(project.plannedEndDate).format('YYYY-MM-DD') : '';
|
||||
|
||||
if (!start && !end) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return `${start || '?'} ~ ${end || '?'}`;
|
||||
}
|
||||
|
||||
/** 仅进行态视为可逾期;已完成/作废/归档不再标逾期 */
|
||||
const OVERDUE_ELIGIBLE_STATUS: Api.Project.ProjectStatusCode[] = ['pending', 'active', 'paused'];
|
||||
|
||||
function overdueDays(project: Api.Project.Project) {
|
||||
if (!project.plannedEndDate || !OVERDUE_ELIGIBLE_STATUS.includes(project.statusCode)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const days = dayjs().startOf('day').diff(dayjs(project.plannedEndDate).startOf('day'), 'day');
|
||||
|
||||
return Math.max(days, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
class="project-grouped-table"
|
||||
height="100%"
|
||||
:data="flatRows"
|
||||
:row-key="getRowKey"
|
||||
:span-method="spanMethod"
|
||||
:row-class-name="rowClassName"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn label="项目名称" min-width="300" align="left">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.rowType === 'dir'" class="pg-dir-line">
|
||||
<ElIcon class="pg-toggle"><ArrowDown /></ElIcon>
|
||||
<span class="pg-dir-chip"></span>
|
||||
<span class="pg-dir-name">
|
||||
{{ getDirectionLabel(row.section.directionCode, row.section.directionCode || '--') }}
|
||||
</span>
|
||||
<span class="pg-dir-meta">
|
||||
{{ row.section.groups.length }} 个产品 · {{ sectionProjectCount(row.section) }} 个项目
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.rowType === 'product'" class="pg-prod-line">
|
||||
<ElIcon class="pg-toggle"><ArrowDown /></ElIcon>
|
||||
<span class="pg-prod-icon" :class="{ 'pg-prod-icon--orphan': row.group.orphan }">
|
||||
<ElIcon>
|
||||
<QuestionFilled v-if="row.group.orphan" />
|
||||
<Collection v-else />
|
||||
</ElIcon>
|
||||
</span>
|
||||
<span class="pg-prod-name" :class="{ 'pg-prod-name--orphan': row.group.orphan }">
|
||||
{{ row.group.productName }}
|
||||
</span>
|
||||
<span v-if="productMetaLabel(row.group)" class="pg-prod-code">{{ productMetaLabel(row.group) }}</span>
|
||||
<span v-if="row.group.orphan" class="pg-prod-code">未挂产品</span>
|
||||
<span v-for="badge in groupTypeBadges(row.group)" :key="badge.value" class="pg-badge">
|
||||
{{ badge.label }} {{ badge.count }}
|
||||
</span>
|
||||
<ElButton
|
||||
v-if="!row.group.orphan"
|
||||
link
|
||||
type="primary"
|
||||
class="pg-add-link"
|
||||
@click.stop="emit('create', row.group)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.rowType === 'hint-empty'" class="pg-hint">
|
||||
该产品暂无项目 ——
|
||||
<ElButton link type="primary" @click.stop="emit('create', row.group)">新增项目</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.rowType === 'project'" class="pg-proj-name">
|
||||
<ElButton link type="primary" class="pg-proj-link" @click="emit('enter', row.project)">
|
||||
{{ row.project.projectName }}
|
||||
</ElButton>
|
||||
<div class="pg-sub-code">{{ row.project.projectCode }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pg-more-line">
|
||||
<template v-if="expandingKeys.has(groupKey(row.group))">
|
||||
<span class="pg-more-link">加载中…</span>
|
||||
</template>
|
||||
<template v-else-if="!revealedProducts.has(groupKey(row.group))">
|
||||
<span class="pg-more-link">
|
||||
<ElIcon class="pg-more-icon"><ArrowDown /></ElIcon>
|
||||
还有 {{ hiddenCount(row.group) }} 个项目,展开查看
|
||||
</span>
|
||||
<span class="pg-more-hint">组内默认只显示前 {{ topN }} 条,按最近更新排序</span>
|
||||
</template>
|
||||
<span v-else class="pg-more-link">
|
||||
<ElIcon class="pg-more-icon pg-more-icon--up"><ArrowDown /></ElIcon>
|
||||
收起
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="项目类型" width="110" align="left" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rowType === 'project'">{{ getTypeLabel(row.project.projectType, '--') }}</template>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="项目经理" width="100" align="left" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rowType === 'project'">{{ projectManagerLabel(row.project) }}</template>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="进度" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.rowType === 'project'" class="pg-progress">
|
||||
<ElProgress
|
||||
:percentage="row.project.progressRate ?? 0"
|
||||
:status="(row.project.progressRate ?? 0) >= 100 ? 'success' : undefined"
|
||||
:stroke-width="16"
|
||||
text-inside
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="计划周期" min-width="210" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.rowType === 'project'">
|
||||
<span class="pg-muted">{{ formatPlannedRange(row.project) }}</span>
|
||||
<div v-if="overdueDays(row.project) > 0" class="pg-overdue">已逾期 {{ overdueDays(row.project) }} 天</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTooltip
|
||||
v-if="row.rowType === 'project'"
|
||||
:content="row.project.lastStatusReason || ''"
|
||||
:disabled="!row.project.lastStatusReason"
|
||||
placement="top"
|
||||
>
|
||||
<ElTag :type="getProjectStatusTagType(row.project.statusCode)">
|
||||
{{ getProjectStatusLabel(row.project.statusCode) }}
|
||||
</ElTag>
|
||||
</ElTooltip>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="最近更新" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.rowType === 'project'" class="pg-muted">{{ formatDate(row.project.updateTime) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<template #empty>
|
||||
<ElEmpty description="当前筛选条件下暂无项目" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-grouped-table {
|
||||
--el-table-row-hover-bg-color: rgb(240 249 255 / 55%);
|
||||
|
||||
// 全局 .el-table .cell { padding: 0 } 把内边距清零了,这里恢复本表的呼吸感
|
||||
:deep(td.el-table__cell > .cell),
|
||||
:deep(th.el-table__cell > .cell) {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
:deep(td.el-table__cell) {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
// 方向节标题行:无底色、上方留白,像章节标题而不是数据行
|
||||
:deep(.pg-dir-row > td.el-table__cell) {
|
||||
padding: 16px 0 6px;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// 产品组行:柔和色带(与左栏卡片同一 slate 语系)
|
||||
:deep(.pg-prod-row > td.el-table__cell) {
|
||||
padding: 9px 0;
|
||||
background: linear-gradient(90deg, rgb(248 250 252 / 98%), rgb(255 255 255 / 90%));
|
||||
border-top: 1px solid var(--el-border-color-extra-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.pg-prod-row:hover > td.el-table__cell) {
|
||||
background: linear-gradient(90deg, rgb(241 245 249 / 98%), rgb(248 250 252 / 92%));
|
||||
}
|
||||
|
||||
:deep(.pg-hint-row > td.el-table__cell) {
|
||||
padding: 5px 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.pg-more-row > td.el-table__cell) {
|
||||
padding: 6px 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.pg-proj-row > td.el-table__cell) {
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-toggle {
|
||||
flex: none;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.is-collapsed .pg-toggle {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
// === 方向节标题 ===
|
||||
.pg-dir-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pg-dir-chip {
|
||||
flex: none;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: rgb(14 165 233 / 85%);
|
||||
}
|
||||
|
||||
.pg-dir-name {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pg-dir-meta {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// === 产品组行 ===
|
||||
.pg-prod-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pg-prod-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
background: rgb(240 249 255 / 96%);
|
||||
color: rgb(2 132 199 / 92%);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pg-prod-icon--orphan {
|
||||
background: rgb(245 243 255 / 96%);
|
||||
color: rgb(124 58 237 / 92%);
|
||||
}
|
||||
|
||||
.pg-prod-name {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pg-prod-name--orphan {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.pg-prod-code {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pg-badge {
|
||||
flex: none;
|
||||
padding: 1px 9px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 88%);
|
||||
color: rgb(71 85 105 / 92%);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pg-add-link {
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// === 提示行 / 项目行 / 收纳行:统一缩进到产品名起始位 ===
|
||||
.pg-hint,
|
||||
.pg-more-line,
|
||||
.pg-proj-name {
|
||||
margin-left: 55px;
|
||||
}
|
||||
|
||||
.pg-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 12px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pg-proj-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pg-sub-code {
|
||||
margin-top: 2px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.pg-progress {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.pg-muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.pg-overdue {
|
||||
margin-top: 2px;
|
||||
color: var(--el-color-danger);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// === 收纳行 ===
|
||||
.pg-more-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pg-more-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pg-more-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pg-more-icon--up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.pg-more-hint {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,8 @@ defineOptions({ name: 'ProjectOperateDialog' });
|
||||
interface Props {
|
||||
managerUserOptions: Api.SystemManage.UserSimple[];
|
||||
rowData?: Api.Project.Project | null;
|
||||
/** 新增模式:预填所属产品(来自分组行"+ 新增"入口) */
|
||||
presetProduct?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -379,10 +381,16 @@ watch(visible, async value => {
|
||||
if (!isEditMode.value || !props.rowData?.id) {
|
||||
editModel.value = createEditModel();
|
||||
createBaseModel.value = createBaseInfo();
|
||||
|
||||
if (props.presetProduct) {
|
||||
createBaseModel.value.productId = props.presetProduct.id;
|
||||
}
|
||||
|
||||
draftMembers.value = [];
|
||||
await nextTick();
|
||||
await loadRoles();
|
||||
editFormRef.value?.clearValidate();
|
||||
baseFormRef.value?.clearValidate();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { Box, CircleCheckFilled, DeleteFilled, DocumentAdd, VideoPause, VideoPlay } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'ProjectOverviewCard' });
|
||||
|
||||
interface StatusNavMeta {
|
||||
key: Api.Project.ProjectStatusCode;
|
||||
label: string;
|
||||
description: string;
|
||||
tone: 'teal' | 'slate' | 'amber' | 'rose' | 'indigo';
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
statusCounts: Record<string, number>;
|
||||
selectedStatus: Api.Project.ProjectStatusCode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'status-change', status: Api.Project.ProjectStatusCode): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const statusNavMetas: StatusNavMeta[] = [
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待开始',
|
||||
description: '项目已创建,等待启动',
|
||||
tone: 'indigo',
|
||||
icon: DocumentAdd
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: '进行中',
|
||||
description: '正在执行的项目',
|
||||
tone: 'teal',
|
||||
icon: VideoPlay
|
||||
},
|
||||
{
|
||||
key: 'paused',
|
||||
label: '已暂停',
|
||||
description: '暂时停止推进的项目',
|
||||
tone: 'amber',
|
||||
icon: VideoPause
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: '已完成',
|
||||
description: '达成目标的项目',
|
||||
tone: 'teal',
|
||||
icon: CircleCheckFilled
|
||||
},
|
||||
{
|
||||
key: 'cancelled',
|
||||
label: '作废项目',
|
||||
description: '已终止或取消推进的项目',
|
||||
tone: 'rose',
|
||||
icon: DeleteFilled
|
||||
},
|
||||
{
|
||||
key: 'archived',
|
||||
label: '归档项目',
|
||||
description: '已收口归档的历史项目',
|
||||
tone: 'slate',
|
||||
icon: Box
|
||||
}
|
||||
];
|
||||
|
||||
const statusItems = computed(() =>
|
||||
statusNavMetas.map(item => ({
|
||||
...item,
|
||||
count: props.statusCounts[item.key] ?? 0
|
||||
}))
|
||||
);
|
||||
|
||||
function handleStatusClick(status: Api.Project.ProjectStatusCode) {
|
||||
emit('status-change', status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="project-overview-card card-wrapper">
|
||||
<div class="project-status-panel__list">
|
||||
<button
|
||||
v-for="item in statusItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="project-status-item"
|
||||
:class="[`project-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
|
||||
:aria-pressed="selectedStatus === item.key"
|
||||
@click="handleStatusClick(item.key)"
|
||||
>
|
||||
<div class="project-status-item__icon">
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="project-status-item__main">
|
||||
<div class="project-status-item__top">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<em>{{ item.count }}</em>
|
||||
</div>
|
||||
<p class="project-status-item__desc">{{ item.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-overview-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(14 165 233 / 8%), transparent 36%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.project-status-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 86%);
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.project-status-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 60%);
|
||||
}
|
||||
|
||||
.project-status-item.is-active {
|
||||
border-color: rgb(14 165 233 / 40%);
|
||||
box-shadow: 0 10px 24px rgb(14 165 233 / 8%);
|
||||
}
|
||||
|
||||
.project-status-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.project-status-item__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-status-item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.project-status-item__top strong {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-status-item__top em {
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-status-item__desc {
|
||||
color: rgb(100 116 139 / 94%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.project-status-item--teal .project-status-item__icon {
|
||||
background-color: rgb(240 253 250 / 96%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
}
|
||||
|
||||
.project-status-item--slate .project-status-item__icon {
|
||||
background-color: rgb(241 245 249 / 96%);
|
||||
color: rgb(51 65 85 / 92%);
|
||||
}
|
||||
|
||||
.project-status-item--amber .project-status-item__icon {
|
||||
background-color: rgb(255 251 235 / 96%);
|
||||
color: rgb(217 119 6 / 92%);
|
||||
}
|
||||
|
||||
.project-status-item--rose .project-status-item__icon {
|
||||
background-color: rgb(255 241 242 / 96%);
|
||||
color: rgb(225 29 72 / 92%);
|
||||
}
|
||||
|
||||
.project-status-item--indigo .project-status-item__icon {
|
||||
background-color: rgb(238 242 255 / 96%);
|
||||
color: rgb(79 70 229 / 92%);
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.project-status-item__top {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectSearch' });
|
||||
|
||||
interface Props {
|
||||
managerOptions: Api.SystemManage.UserSimple[];
|
||||
productOptions: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -18,7 +18,7 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Project.ProjectSearchParams>('model', { required: true });
|
||||
const model = defineModel<Api.Project.ProjectGroupSearchParams>('model', { required: true });
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
@@ -28,11 +28,14 @@ const fields = computed<SearchField[]>(() => [
|
||||
placeholder: '项目编码 / 名称'
|
||||
},
|
||||
{
|
||||
key: 'directionCode',
|
||||
label: '项目方向',
|
||||
type: 'dict',
|
||||
dictCode: RDMS_OBJECT_DIRECTION_DICT_CODE,
|
||||
placeholder: '筛选项目方向'
|
||||
key: 'productId',
|
||||
label: '所属产品',
|
||||
type: 'select',
|
||||
options: props.productOptions.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
})),
|
||||
placeholder: '筛选所属产品'
|
||||
},
|
||||
{
|
||||
key: 'projectType',
|
||||
@@ -40,16 +43,6 @@ const fields = computed<SearchField[]>(() => [
|
||||
type: 'dict',
|
||||
dictCode: RDMS_PROJECT_TYPE_DICT_CODE,
|
||||
placeholder: '筛选项目类型'
|
||||
},
|
||||
{
|
||||
key: 'managerUserId',
|
||||
label: '项目经理',
|
||||
type: 'select',
|
||||
options: props.managerOptions.map(item => ({
|
||||
label: item.nickname,
|
||||
value: item.id
|
||||
})),
|
||||
placeholder: '筛选项目经理'
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
308
src/views/project/list/modules/project-status-rail.vue
Normal file
308
src/views/project/list/modules/project-status-rail.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
/** 项目列表左栏导航键:项目状态编码(状态机动态下发,不再用字面量联合约束)+ 'all' + 'orphan' */
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Box,
|
||||
CircleCheckFilled,
|
||||
DeleteFilled,
|
||||
Document,
|
||||
DocumentAdd,
|
||||
Menu,
|
||||
QuestionFilled,
|
||||
VideoPause,
|
||||
VideoPlay
|
||||
} from '@element-plus/icons-vue';
|
||||
export type ProjectListNavKey = string;
|
||||
|
||||
defineOptions({ name: 'ProjectStatusRail' });
|
||||
|
||||
type NavTone = 'sky' | 'teal' | 'slate' | 'amber' | 'rose' | 'indigo' | 'violet';
|
||||
|
||||
interface NavItemView {
|
||||
key: ProjectListNavKey;
|
||||
label: string;
|
||||
tone: NavTone;
|
||||
icon: Component;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 状态看板项(overview-summary items,后端已按 sort 升序) */
|
||||
items: Api.Project.OverviewStatusItem[];
|
||||
/** "全部"口径总数(直接用后端 total,前端不自行求和或排除) */
|
||||
total: number;
|
||||
/** 游离项目计数(>0 才显示游离入口) */
|
||||
orphanCount: number;
|
||||
selected: ProjectListNavKey;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'change', key: ProjectListNavKey): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
interface StatusVisualMeta {
|
||||
tone: NavTone;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
/** 状态视觉资产(icon/tone)是前端本地映射;状态名直接渲染后端 statusName,不做本地名称映射 */
|
||||
const STATUS_VISUALS: Record<string, StatusVisualMeta> = {
|
||||
active: { tone: 'teal', icon: VideoPlay },
|
||||
pending: { tone: 'indigo', icon: DocumentAdd },
|
||||
paused: { tone: 'amber', icon: VideoPause },
|
||||
completed: { tone: 'teal', icon: CircleCheckFilled },
|
||||
cancelled: { tone: 'rose', icon: DeleteFilled },
|
||||
archived: { tone: 'slate', icon: Box }
|
||||
};
|
||||
|
||||
/** 状态机新增状态未配置视觉资产时的默认兜底:通用图标 + 中性色 */
|
||||
const DEFAULT_STATUS_VISUAL: StatusVisualMeta = { tone: 'slate', icon: Document };
|
||||
|
||||
/**
|
||||
* 终态分区(分隔线下方)是前端视觉决策的写死名单:completed 在状态机里也可能是终态但业务上放主区,
|
||||
* 因此不能用后端 terminal 标志分组。
|
||||
*/
|
||||
const TERMINAL_SECTION_CODES = new Set<string>(['cancelled', 'archived']);
|
||||
|
||||
const ORPHAN_ITEM = {
|
||||
key: 'orphan',
|
||||
label: '游离项目',
|
||||
tone: 'violet',
|
||||
icon: QuestionFilled
|
||||
} as const;
|
||||
|
||||
function toNavItem(item: Api.Project.OverviewStatusItem): NavItemView {
|
||||
const visual = STATUS_VISUALS[item.statusCode] ?? DEFAULT_STATUS_VISUAL;
|
||||
|
||||
return {
|
||||
key: item.statusCode,
|
||||
label: item.statusName,
|
||||
tone: visual.tone,
|
||||
icon: visual.icon,
|
||||
count: item.count
|
||||
};
|
||||
}
|
||||
|
||||
const mainItems = computed<NavItemView[]>(() => [
|
||||
{ key: 'all', label: '全部项目', tone: 'sky', icon: Menu, count: props.total },
|
||||
...props.items.filter(item => !TERMINAL_SECTION_CODES.has(item.statusCode)).map(toNavItem)
|
||||
]);
|
||||
|
||||
const terminalItems = computed<NavItemView[]>(() =>
|
||||
props.items.filter(item => TERMINAL_SECTION_CODES.has(item.statusCode)).map(toNavItem)
|
||||
);
|
||||
|
||||
function handleClick(key: ProjectListNavKey) {
|
||||
emit('change', key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="project-status-rail card-wrapper">
|
||||
<div class="project-status-rail__list">
|
||||
<button
|
||||
v-for="item in mainItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="project-status-item"
|
||||
:class="[`project-status-item--${item.tone}`, { 'is-active': selected === item.key }]"
|
||||
:aria-pressed="selected === item.key"
|
||||
@click="handleClick(item.key)"
|
||||
>
|
||||
<div class="project-status-item__icon">
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="project-status-item__main">
|
||||
<div class="project-status-item__top">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<em>{{ item.count }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="terminalItems.length" class="project-status-rail__divider"></div>
|
||||
|
||||
<button
|
||||
v-for="item in terminalItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="project-status-item"
|
||||
:class="[`project-status-item--${item.tone}`, { 'is-active': selected === item.key }]"
|
||||
:aria-pressed="selected === item.key"
|
||||
@click="handleClick(item.key)"
|
||||
>
|
||||
<div class="project-status-item__icon">
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="project-status-item__main">
|
||||
<div class="project-status-item__top">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<em>{{ item.count }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<template v-if="orphanCount > 0">
|
||||
<div class="project-status-rail__divider"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="project-status-item"
|
||||
:class="[`project-status-item--${ORPHAN_ITEM.tone}`, { 'is-active': selected === 'orphan' }]"
|
||||
:aria-pressed="selected === 'orphan'"
|
||||
@click="handleClick('orphan')"
|
||||
>
|
||||
<div class="project-status-item__icon">
|
||||
<ElIcon>
|
||||
<component :is="ORPHAN_ITEM.icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="project-status-item__main">
|
||||
<div class="project-status-item__top">
|
||||
<strong>{{ ORPHAN_ITEM.label }}</strong>
|
||||
<em>{{ orphanCount }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-status-rail {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(14 165 233 / 8%), transparent 36%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.project-status-rail__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-status-rail__divider {
|
||||
height: 1px;
|
||||
margin: 2px 4px;
|
||||
background: var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.project-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 86%);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.project-status-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 60%);
|
||||
}
|
||||
|
||||
.project-status-item.is-active {
|
||||
border-color: rgb(14 165 233 / 40%);
|
||||
box-shadow: 0 10px 24px rgb(14 165 233 / 8%);
|
||||
}
|
||||
|
||||
.project-status-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.project-status-item__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-status-item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-status-item__top strong {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-status-item__top em {
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-status-item--sky .project-status-item__icon {
|
||||
background-color: rgb(240 249 255 / 96%);
|
||||
color: rgb(2 132 199 / 96%);
|
||||
}
|
||||
|
||||
.project-status-item--teal .project-status-item__icon {
|
||||
background-color: rgb(240 253 250 / 96%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
}
|
||||
|
||||
.project-status-item--slate .project-status-item__icon {
|
||||
background-color: rgb(241 245 249 / 96%);
|
||||
color: rgb(51 65 85 / 92%);
|
||||
}
|
||||
|
||||
.project-status-item--amber .project-status-item__icon {
|
||||
background-color: rgb(255 251 235 / 96%);
|
||||
color: rgb(217 119 6 / 92%);
|
||||
}
|
||||
|
||||
.project-status-item--rose .project-status-item__icon {
|
||||
background-color: rgb(255 241 242 / 96%);
|
||||
color: rgb(225 29 72 / 92%);
|
||||
}
|
||||
|
||||
.project-status-item--indigo .project-status-item__icon {
|
||||
background-color: rgb(238 242 255 / 96%);
|
||||
color: rgb(79 70 229 / 92%);
|
||||
}
|
||||
|
||||
.project-status-item--violet .project-status-item__icon {
|
||||
background-color: rgb(245 243 255 / 96%);
|
||||
color: rgb(124 58 237 / 92%);
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.project-status-item__top {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -23,13 +23,14 @@ export const projectStatusActionRecord: Record<Api.Project.ProjectStatusActionCo
|
||||
archive: '归档项目'
|
||||
};
|
||||
|
||||
export function getProjectStatusLabel(status: Api.Project.ProjectStatusCode) {
|
||||
return projectStatusRecord[status];
|
||||
/** 状态编码来自状态机动态下发,未配置的新状态回退编码本身(展示名优先用后端 statusName) */
|
||||
export function getProjectStatusLabel(status: string) {
|
||||
return (projectStatusRecord as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
|
||||
/** 根据项目状态返回对应的 Tag 类型,用于 ElTag 组件的颜色映射 */
|
||||
export function getProjectStatusTagType(status: Api.Project.ProjectStatusCode): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<Api.Project.ProjectStatusCode, UI.ThemeColor> = {
|
||||
/** 根据项目状态返回对应的 Tag 类型,用于 ElTag 组件的颜色映射;未配置的新状态回退 info */
|
||||
export function getProjectStatusTagType(status: string): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<string, UI.ThemeColor> = {
|
||||
pending: 'info',
|
||||
active: 'success',
|
||||
paused: 'warning',
|
||||
@@ -38,7 +39,7 @@ export function getProjectStatusTagType(status: Api.Project.ProjectStatusCode):
|
||||
archived: 'info'
|
||||
};
|
||||
|
||||
return statusTagTypeMap[status];
|
||||
return statusTagTypeMap[status] ?? 'info';
|
||||
}
|
||||
|
||||
/** 判断项目是否可编辑:pending / active / paused 状态允许编辑 */
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetRecentNotices } from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { formatRelativeTime } from '@/utils/datetime';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { getGreeting } from '../homepage';
|
||||
|
||||
defineOptions({ name: 'WorkbenchBanner' });
|
||||
|
||||
interface NoticeRow {
|
||||
id: string;
|
||||
title: string;
|
||||
timeLabel: string;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '同学');
|
||||
const greeting = computed(() => getGreeting());
|
||||
@@ -26,19 +23,45 @@ const dateContext = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
// 公告 mock:banner 阶段本地维护,等公告中心接口落地再迁移至 mock.ts
|
||||
const allNotices: NoticeRow[] = [
|
||||
{ id: 'n1', title: '【运维】本周六 02:00-04:00 数据库主从切换', timeLabel: '2 天前' },
|
||||
{ id: 'n2', title: '【HR】Q2 OKR 复盘截止 06-05', timeLabel: '3 天前' },
|
||||
{ id: 'n3', title: '【流程】工单 SLA 新规则即将上线', timeLabel: '1 周前' },
|
||||
{ id: 'n4', title: '【系统】新版本 25.06 发布日程公告', timeLabel: '2 周前' },
|
||||
{ id: 'n5', title: '【行政】6 月端午节放假安排', timeLabel: '3 周前' },
|
||||
{ id: 'n6', title: '【安全】禁止使用未受控外部 AI 工具处理客户数据', timeLabel: '1 个月前' }
|
||||
];
|
||||
// 「全部公告」抽屉无独立菜单/权限码,只能走登录即可的 recent 接口,取最新 50 条兜底
|
||||
const NOTICE_FETCH_SIZE = 50;
|
||||
|
||||
const previewNotices = computed(() => allNotices.slice(0, 3));
|
||||
const allNotices = ref<Api.Notice.Notice[]>([]);
|
||||
const noticesLoading = ref(false);
|
||||
|
||||
async function loadNotices() {
|
||||
noticesLoading.value = true;
|
||||
const { data, error } = await fetchGetRecentNotices(NOTICE_FETCH_SIZE);
|
||||
noticesLoading.value = false;
|
||||
if (error || !data) return;
|
||||
allNotices.value = data;
|
||||
}
|
||||
|
||||
onMounted(loadNotices);
|
||||
|
||||
const previewNotices = computed(() => allNotices.value.slice(0, 3));
|
||||
const drawerOpen = ref(false);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailNotice = ref<Api.Notice.Notice | null>(null);
|
||||
|
||||
function openNoticeDetail(row: Api.Notice.Notice) {
|
||||
detailNotice.value = row;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
// 公告内容可能为富文本,列表行只取纯文本做单行预览
|
||||
function toNoticeSnippet(html: string) {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const detailTimeLabel = computed(() =>
|
||||
detailNotice.value ? dayjs(detailNotice.value.createTime).format('YYYY-MM-DD HH:mm') : ''
|
||||
);
|
||||
|
||||
function openDrawer() {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
@@ -71,27 +94,58 @@ function closeDrawer() {
|
||||
<SvgIcon icon="mdi:arrow-right" />
|
||||
</button>
|
||||
</header>
|
||||
<ul class="workbench-banner__notice-list">
|
||||
<li v-for="row in previewNotices" :key="row.id" class="workbench-banner__notice-row">
|
||||
<span class="workbench-banner__notice-row-title">{{ row.title }}</span>
|
||||
<span class="workbench-banner__notice-row-time">{{ row.timeLabel }}</span>
|
||||
<ul v-if="previewNotices.length > 0" class="workbench-banner__notice-list">
|
||||
<li
|
||||
v-for="row in previewNotices"
|
||||
:key="row.id"
|
||||
class="workbench-banner__notice-row"
|
||||
@click="openNoticeDetail(row)"
|
||||
>
|
||||
<div class="workbench-banner__notice-row-main">
|
||||
<span class="workbench-banner__notice-row-title">{{ row.title }}</span>
|
||||
<span class="workbench-banner__notice-row-time">{{ formatRelativeTime(row.createTime) }}</span>
|
||||
</div>
|
||||
<div v-if="row.content" class="workbench-banner__notice-row-snippet">{{ toNoticeSnippet(row.content) }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="workbench-banner__notice-empty">
|
||||
{{ noticesLoading ? '加载中…' : '暂无公告' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElDrawer v-model="drawerOpen" title="全部公告" size="480px">
|
||||
<ElScrollbar>
|
||||
<ul class="workbench-banner__drawer-list">
|
||||
<li v-for="row in allNotices" :key="row.id" class="workbench-banner__drawer-row">
|
||||
<ul v-if="allNotices.length > 0" class="workbench-banner__drawer-list">
|
||||
<li
|
||||
v-for="row in allNotices"
|
||||
:key="row.id"
|
||||
class="workbench-banner__drawer-row"
|
||||
@click="openNoticeDetail(row)"
|
||||
>
|
||||
<div class="workbench-banner__drawer-row-title">{{ row.title }}</div>
|
||||
<div class="workbench-banner__drawer-row-time">{{ row.timeLabel }}</div>
|
||||
<div v-if="row.content" class="workbench-banner__drawer-row-snippet">
|
||||
{{ toNoticeSnippet(row.content) }}
|
||||
</div>
|
||||
<div class="workbench-banner__drawer-row-time">{{ formatRelativeTime(row.createTime) }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="workbench-banner__notice-empty">暂无公告</div>
|
||||
</ElScrollbar>
|
||||
<template #footer>
|
||||
<ElButton @click="closeDrawer">关闭</ElButton>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
|
||||
<BusinessFormDialog v-model="detailOpen" title="公告详情" width="560px">
|
||||
<template v-if="detailNotice">
|
||||
<h3 class="workbench-banner__detail-title">{{ detailNotice.title }}</h3>
|
||||
<p class="workbench-banner__detail-time">{{ detailTimeLabel }}</p>
|
||||
<BusinessRichTextView :value="detailNotice.content" />
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<ElButton @click="close">关闭</ElButton>
|
||||
</template>
|
||||
</BusinessFormDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -209,7 +263,9 @@ function closeDrawer() {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 108px;
|
||||
max-height: 156px;
|
||||
/* 行项负 margin 出血会把 overflow-x 撑出横向滚动条,显式裁掉 */
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -223,25 +279,40 @@ function closeDrawer() {
|
||||
}
|
||||
|
||||
.workbench-banner__notice-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
margin: 0 -6px;
|
||||
border-radius: 8px;
|
||||
border-bottom: 1px dashed rgb(226 232 240 / 70%);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.workbench-banner__notice-row:hover {
|
||||
background-color: rgb(241 245 249 / 80%);
|
||||
}
|
||||
|
||||
.workbench-banner__notice-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.workbench-banner__notice-row-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workbench-banner__notice-row-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: rgb(30 41 59 / 96%);
|
||||
font-size: 13px;
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workbench-banner__notice-row-time {
|
||||
@@ -250,15 +321,39 @@ function closeDrawer() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbench-banner__notice-row-snippet {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workbench-banner__notice-empty {
|
||||
padding: 16px 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.workbench-banner__drawer-list {
|
||||
margin: 0;
|
||||
padding: 0 4px 0 0;
|
||||
list-style: none;
|
||||
/* 同款负 margin 出血,裁掉横向溢出,避免传到 ElScrollbar */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbench-banner__drawer-row {
|
||||
padding: 12px 0;
|
||||
padding: 12px 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 8px;
|
||||
border-bottom: 1px solid rgb(226 232 240 / 80%);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.workbench-banner__drawer-row:hover {
|
||||
background-color: rgb(241 245 249 / 80%);
|
||||
}
|
||||
|
||||
.workbench-banner__drawer-row:last-child {
|
||||
@@ -267,16 +362,40 @@ function closeDrawer() {
|
||||
|
||||
.workbench-banner__drawer-row-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workbench-banner__drawer-row-snippet {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: rgb(71 85 105 / 92%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.workbench-banner__drawer-row-time {
|
||||
margin-top: 4px;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workbench-banner__detail-title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.workbench-banner__detail-time {
|
||||
margin: 6px 0 14px;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.workbench-banner {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user