feat(projects): 1、站内信、通知功能完善;2、项目列表按会议需求重新开发

This commit is contained in:
2026-06-11 14:02:26 +08:00
parent d53a8dfae5
commit 0652a24c5e
26 changed files with 2064 additions and 768 deletions

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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>

View 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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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: '筛选项目经理'
}
]);

View 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>

View File

@@ -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 状态允许编辑 */

View File

@@ -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(() => {
};
});
// 公告 mockbanner 阶段本地维护,等公告中心接口落地再迁移至 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;