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

@@ -1,76 +1,121 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useInfiniteScroll } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
import {
fetchGetMyNotifyMessagePage,
fetchGetUnreadNotifyCount,
fetchUpdateAllNotifyMessageRead,
fetchUpdateNotifyMessageRead
} from '@/service/api';
import { formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' });
interface NotificationItem {
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 30 * 1000;
// 通知 mock扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
function buildMockNotifications(): NotificationItem[] {
const titles = [
'你被指派为执行「迭代 24.06」负责人',
'任务「SSO 改造」状态变更:开发中 → 待验收',
'需求「多币种支持」评审通过',
'工单 #1042 已分派给你',
'需求「订单导出」被退回,请补充材料',
'@ 你的评论已被回复',
'项目「客户中心 2.0」周报已生成',
'工单 #1098 客户回复待处理',
'执行「迭代 24.05」已结束',
'需求「批量审批」分配给你'
];
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
return Array.from({ length: 60 }, (_, i) => ({
id: `m${i + 1}`,
title: `${titles[i % titles.length]}#${i + 1}`,
timeLabel: times[Math.floor(i / 6) % times.length],
unread: i < 14
}));
type TabKey = 'unread' | 'read';
interface MessageListState {
items: Api.NotifyMessage.NotifyMessage[];
pageNo: number;
total: number;
loading: boolean;
/** 是否已按当前关键字拉过第一页tab 懒加载 / 失效重拉用) */
loaded: boolean;
/** 竞态令牌:重置后递增,过期响应直接丢弃 */
token: number;
}
const notifications = ref<NotificationItem[]>(buildMockNotifications());
function createListState(): MessageListState {
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
}
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
const readAll = computed(() => notifications.value.filter(n => !n.unread));
const unreadCount = computed(() => unreadAll.value.length);
const listStates = reactive<Record<TabKey, MessageListState>>({
unread: createListState(),
read: createListState()
});
const unreadCount = ref(0);
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
const drawerOpen = ref(false);
const activeTab = ref<'unread' | 'read'>('unread');
const activeTab = ref<TabKey>('unread');
const searchKeyword = ref('');
function matchesKeyword(item: NotificationItem) {
const kw = searchKeyword.value.trim();
if (!kw) return true;
return item.title.toLowerCase().includes(kw.toLowerCase());
function keywordParam() {
return searchKeyword.value.trim() || undefined;
}
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
async function refreshUnreadCount() {
const { data, error } = await fetchGetUnreadNotifyCount();
if (!error && typeof data === 'number') {
unreadCount.value = data;
}
}
const unreadPageSize = ref(PAGE_SIZE);
const readPageSize = ref(PAGE_SIZE);
function resetList(tab: TabKey) {
const state = listStates[tab];
state.token += 1;
state.items = [];
state.pageNo = 1;
state.total = 0;
state.loading = false;
state.loaded = false;
}
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
async function loadPage(tab: TabKey) {
const state = listStates[tab];
if (state.loading) return;
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
const token = state.token;
state.loading = true;
const { data, error } = await fetchGetMyNotifyMessagePage({
pageNo: state.pageNo,
pageSize: PAGE_SIZE,
readStatus: tab === 'read',
keyword: keywordParam()
});
if (token !== state.token) return;
state.loading = false;
state.loaded = true;
if (error || !data) return;
state.items.push(...data.list);
state.total = data.total;
state.pageNo += 1;
}
function hasMore(tab: TabKey) {
const state = listStates[tab];
return state.loaded && state.items.length < state.total;
}
function ensureLoaded(tab: TabKey) {
const state = listStates[tab];
if (!state.loaded && !state.loading) {
loadPage(tab);
}
}
const applyKeywordSearch = useDebounceFn(() => {
if (!drawerOpen.value) return;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}, 300);
watch(searchKeyword, () => {
unreadPageSize.value = PAGE_SIZE;
readPageSize.value = PAGE_SIZE;
applyKeywordSearch();
});
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
watch(activeTab, tab => {
ensureLoaded(tab);
});
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
const unreadScrollbar = ref<ScrollbarRefValue>(null);
@@ -79,7 +124,9 @@ const readScrollbar = ref<ScrollbarRefValue>(null);
useInfiniteScroll(
() => unreadScrollbar.value?.wrapRef,
() => {
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
loadPage('unread');
}
},
{ distance: 48 }
);
@@ -87,43 +134,78 @@ useInfiniteScroll(
useInfiniteScroll(
() => readScrollbar.value?.wrapRef,
() => {
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
loadPage('read');
}
},
{ distance: 48 }
);
function openDrawer() {
drawerOpen.value = true;
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
resetList('unread');
resetList('read');
loadPage(activeTab.value);
refreshUnreadCount();
}
function closeDrawer() {
drawerOpen.value = false;
}
function markRead(item: NotificationItem) {
if (!item.unread) return;
item.unread = false;
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', item.id);
}
function markAllRead() {
notifications.value.forEach(item => {
item.unread = false;
});
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
function openItem(item: NotificationItem) {
markRead(item);
// eslint-disable-next-line no-console
console.warn('[notification] open', item.id);
}
function onDrawerClosed() {
searchKeyword.value = '';
}
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
if (error) return;
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
const state = listStates.unread;
const index = state.items.findIndex(row => row.id === item.id);
if (index >= 0) {
state.items.splice(index, 1);
state.total = Math.max(0, state.total - 1);
}
unreadCount.value = Math.max(0, unreadCount.value - 1);
// 已读列表失效,下次进入已读 tab 时从第 1 页重拉
resetList('read');
// 移除后剩余条目不足一页且还有更多时补拉,防止列表不再触发滚动加载
if (state.items.length < PAGE_SIZE && hasMore('unread')) {
loadPage('unread');
}
}
async function markAllRead() {
const { error } = await fetchUpdateAllNotifyMessageRead();
if (error) return;
unreadCount.value = 0;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
refreshUnreadCount();
pollTimer = setInterval(() => {
if (document.hidden) return;
refreshUnreadCount();
}, UNREAD_COUNT_POLL_INTERVAL);
});
onBeforeUnmount(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template>
@@ -137,21 +219,18 @@ function onDrawerClosed() {
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
</button>
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed">
<div class="notification-bell__panel">
<header class="notification-bell__header">
<ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
<template #header>
<div class="notification-bell__header-main">
<span class="notification-bell__title">
通知
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
</span>
<span class="notification-bell__header-actions">
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
<SvgIcon icon="mdi:close" />
</button>
</span>
</header>
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
</div>
</template>
<div class="notification-bell__panel">
<div class="notification-bell__search">
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
<template #prefix>
@@ -165,29 +244,29 @@ function onDrawerClosed() {
<template #label>
<span class="notification-bell__tab-label">
未读
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span>
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
</span>
</template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
<ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
<li
v-for="row in visibleUnread"
v-for="row in listStates.unread.items"
:key="row.id"
class="notification-bell__row is-unread"
@click="openItem(row)"
@click="markRead(row)"
>
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
</div>
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
@@ -196,29 +275,33 @@ function onDrawerClosed() {
<template #label>
<span class="notification-bell__tab-label">
已读
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
<span class="notification-bell__tab-count">{{ listStates.read.total }}</span>
</span>
</template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
<ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
<li v-for="row in listStates.read.items" :key="row.id" class="notification-bell__row">
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
</div>
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
<div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
{{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
<template #footer>
<ElButton @click="closeDrawer">关闭</ElButton>
</template>
</ElDrawer>
</template>
@@ -278,13 +361,14 @@ function onDrawerClosed() {
height: 100%;
}
.notification-bell__header {
.notification-bell__header-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
min-width: 0;
margin-right: 8px;
}
.notification-bell__title {
@@ -305,37 +389,8 @@ function onDrawerClosed() {
font-weight: 600;
}
.notification-bell__header-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.notification-bell__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 18px;
transition:
background-color 120ms ease,
color 120ms ease;
}
.notification-bell__close:hover {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-primary);
}
.notification-bell__search {
padding: 12px 0 4px;
padding: 0 0 4px;
}
.notification-bell__tabs {
@@ -393,16 +448,19 @@ function onDrawerClosed() {
align-items: flex-start;
gap: 10px;
padding: 12px 4px;
cursor: pointer;
border-radius: 8px;
transition: background-color 120ms ease;
}
.notification-bell__row + .notification-bell__row {
border-top: 1px dashed var(--el-border-color-lighter);
}
.notification-bell__row:hover {
.notification-bell__row.is-unread {
cursor: pointer;
transition: background-color 120ms ease;
}
.notification-bell__row.is-unread:hover {
background-color: var(--el-fill-color-light);
}

View File

@@ -2,11 +2,14 @@ export * from './auth';
export * from './dict';
export * from './file';
export * from './infra';
export * from './notice';
export * from './notify-message';
export * from './object-context';
export * from './overtime-application';
export * from './personal-item';
export * from './product';
export * from './project';
export * from './project-group';
export * from './project-shared';
export * from './route';
export * from './system-manage';

28
src/service/api/notice.ts Normal file
View File

@@ -0,0 +1,28 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const NOTICE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notice`;
type NoticeResponse = Omit<Api.Notice.Notice, 'id'> & {
id: string | number;
};
function normalizeNotice(data: NoticeResponse): Api.Notice.Notice {
return {
...data,
id: normalizeStringId(data.id)
};
}
/** 获取最近公告status=0按 id 倒序;登录即可,工作台公告卡片用) */
export async function fetchGetRecentNotices(size?: number) {
const result = await request<NoticeResponse[]>({
url: `${NOTICE_PREFIX}/recent`,
method: 'get',
params: { size },
...safeJsonRequestConfig
});
return mapServiceResult(result as ServiceRequestResult<NoticeResponse[]>, data => data.map(normalizeNotice));
}

View File

@@ -0,0 +1,60 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
const NOTIFY_MESSAGE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notify-message`;
type NotifyMessageResponse = Omit<Api.NotifyMessage.NotifyMessage, 'id'> & {
id: string | number;
};
type MyNotifyMessagePageResponse = Omit<Api.NotifyMessage.PageResult<Api.NotifyMessage.NotifyMessage>, 'list'> & {
list: NotifyMessageResponse[];
};
function normalizeNotifyMessage(data: NotifyMessageResponse): Api.NotifyMessage.NotifyMessage {
return {
...data,
id: normalizeStringId(data.id)
};
}
/** 获取当前用户未读站内信数量(铃铛红点轮询用) */
export function fetchGetUnreadNotifyCount() {
return request<number>({
url: `${NOTIFY_MESSAGE_PREFIX}/get-unread-count`,
method: 'get'
});
}
/** 分页获取我的站内信(消息列表唯一数据源;未读传 readStatus=false、已读传 true */
export async function fetchGetMyNotifyMessagePage(params: Api.NotifyMessage.MyPageParams) {
const result = await request<MyNotifyMessagePageResponse>({
url: `${NOTIFY_MESSAGE_PREFIX}/my-page`,
method: 'get',
params,
...safeJsonRequestConfig
});
return mapServiceResult(result as ServiceRequestResult<MyNotifyMessagePageResponse>, data => ({
...data,
list: data.list.map(normalizeNotifyMessage)
}));
}
/** 批量标记站内信已读(后端幂等:重复提交、非本人条目均安全) */
export function fetchUpdateNotifyMessageRead(ids: string[]) {
// 后端约定 ids 逗号分隔
return request<boolean>({
url: `${NOTIFY_MESSAGE_PREFIX}/update-read?ids=${ids.join(',')}`,
method: 'put'
});
}
/** 当前用户全部站内信标记已读 */
export function fetchUpdateAllNotifyMessageRead() {
return request<boolean>({
url: `${NOTIFY_MESSAGE_PREFIX}/update-all-read`,
method: 'put'
});
}

View File

@@ -106,13 +106,34 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
}));
}
type ProductOverviewSummaryResponse = Omit<Api.Product.ProductOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;
items?: Api.Product.OverviewStatusItem[] | null;
};
/** 归一化产品概览统计total/items 兜底,保证业务层拿到完整结构 */
function normalizeProductOverviewSummary(data: ProductOverviewSummaryResponse): Api.Product.ProductOverviewSummary {
return {
...data,
statusCounts: data.statusCounts ?? {},
total: data.total ?? 0,
items: data.items ?? []
};
}
/** 获取产品入口页概览统计 */
export function fetchGetProductOverviewSummary() {
return request<Api.Product.ProductOverviewSummary>({
export async function fetchGetProductOverviewSummary() {
const result = await request<ProductOverviewSummaryResponse>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/overview-summary`,
method: 'get'
});
return mapServiceResult(
result as ServiceRequestResult<ProductOverviewSummaryResponse>,
normalizeProductOverviewSummary
);
}
/** 获取产品详情 */

View File

@@ -0,0 +1,62 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
safeJsonRequestConfig
} from './shared';
import { type ProjectResponse, normalizeProject } from './project';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
/**
* group-page 原始响应。
* 组级 managerUserId、productId后端对小数值 Long如 1001仍按数字返回需 String() 归一;
* projects 字段与 page 接口项目行完全一致,复用 ProjectResponse / normalizeProject。
*/
type ProjectGroupResponse = Omit<Api.Project.ProjectGroup, 'productId' | 'managerUserId' | 'projects'> & {
productId?: string | number | null;
managerUserId?: string | number | null;
projects: ProjectResponse[];
};
type ProjectGroupPageResponse = Omit<Api.Project.ProjectGroupPageResult, 'list'> & {
list: ProjectGroupResponse[];
};
/** 归一化分组:组级 ID String 化,组内项目复用 normalizeProjectid/managerUserId/productId/日期统一口径) */
function normalizeProjectGroup(group: ProjectGroupResponse): Api.Project.ProjectGroup {
return {
...group,
productId: normalizeNullableStringId(group.productId),
managerUserId: normalizeNullableStringId(group.managerUserId),
projects: Array.isArray(group.projects) ? group.projects.map(normalizeProject) : []
};
}
/**
* 项目列表「按产品分组」分页。
*
* 后端契约见《项目列表产品分组-前端API-2026-06-10》
* - pageNo/pageSize 为产品组维度分页statusCode 不传 = 「全部」口径(后端从状态机推导,
* 当前等价 pending/active/paused/completed不含 cancelled/archived
* - 组内 projects 仅返前 topN 条(默认 5projectTotal 为该口径组内全量计数;
* 剩余项目由页面按 productId / orphanOnly + statusCodes 走 page 接口展开拉取。
* - typeCounts / hasBaseline 现状恒按「全部」口径统计,不随 statusCode 变化;其中 typeCounts 已提需求
* 改为与 projectTotal 同口径见《2026-06-11-项目分组接口typeCounts口径-后端接口需求》),后端落地后更新本注释;
* hasBaseline = 存在非已取消的主线项目(已归档/完成也算占坑),前端直接消费、不自行推导。
*/
export async function fetchGetProjectGroupPage(params?: Api.Project.ProjectGroupSearchParams) {
const result = await request<ProjectGroupPageResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/group-page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectGroupPageResponse>, data => ({
...data,
list: Array.isArray(data.list) ? data.list.map(normalizeProjectGroup) : []
}));
}

View File

@@ -37,7 +37,7 @@ import {
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
type ProjectResponse = Omit<
export type ProjectResponse = Omit<
Api.Project.Project,
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
> & {
@@ -79,7 +79,7 @@ function getTaskPrefix(projectId: string, executionId: string) {
}
/** 归一化项目数据 */
function normalizeProject(project: ProjectResponse): Api.Project.Project {
export function normalizeProject(project: ProjectResponse): Api.Project.Project {
return {
...project,
id: normalizeStringId(project.id),
@@ -136,13 +136,34 @@ export async function fetchGetProjectPage(params?: Api.Project.ProjectSearchPara
}));
}
type ProjectOverviewSummaryResponse = Omit<Api.Project.ProjectOverviewSummary, 'total' | 'items'> & {
/** 后端 overview-summary 升级total/items灰度期间可能缺省适配层兜底 */
total?: number | null;
items?: Api.Project.OverviewStatusItem[] | null;
};
/** 归一化项目概览统计total/items 兜底,保证业务层拿到完整结构 */
function normalizeProjectOverviewSummary(data: ProjectOverviewSummaryResponse): Api.Project.ProjectOverviewSummary {
return {
...data,
statusCounts: data.statusCounts ?? {},
total: data.total ?? 0,
items: data.items ?? []
};
}
/** 获取项目入口页概览统计 */
export function fetchGetProjectOverviewSummary() {
return request<Api.Project.ProjectOverviewSummary>({
export async function fetchGetProjectOverviewSummary() {
const result = await request<ProjectOverviewSummaryResponse>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/overview-summary`,
method: 'get'
});
return mapServiceResult(
result as ServiceRequestResult<ProjectOverviewSummaryResponse>,
normalizeProjectOverviewSummary
);
}
/** 获取项目详情 */

View File

@@ -131,6 +131,12 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
// If the tab needs to be cleared,it means we don't need to redirect.
needRedirect = false;
}
// 跳首页前先把权限路由建好:菜单/路由/首页 redirect 全部就绪后再导航,
// 否则依赖守卫在"跳首页"那次导航里懒加载,会出现首页先以空 menus 渲染、
// 之后无新导航补灌、菜单一直空到手动刷新才恢复的竞态。
await routeStore.initAuthRoute();
await redirectFromLogin(needRedirect);
window.$notification?.success({

24
src/typings/api/notice.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare namespace Api {
/**
* namespace Notice
*
* backend api module: "notice"(通知公告)
*/
namespace Notice {
/** 公告ID 在 API 适配层已统一为 string */
interface Notice {
/** 公告编号 */
id: string;
/** 公告标题 */
title: string;
/** 公告类型,字典 system_notice_type */
type: number;
/** 公告内容(富文本 / 纯文本,由录入决定) */
content: string;
/** 状态0 开启 / 1 关闭 */
status: number;
/** 创建时间 */
createTime: string | number;
}
}
}

44
src/typings/api/notify-message.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
declare namespace Api {
/**
* namespace NotifyMessage
*
* backend api module: "notify-message"(站内信 · 我的收件箱)
*/
namespace NotifyMessage {
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T = any> {
total: number;
list: T[];
}
/** 站内信(铃铛 / 收件箱展示用ID 在 API 适配层已统一为 string */
interface NotifyMessage {
/** 站内信编号(雪花 Long按 string 接收) */
id: string;
/** 发送人名称(模板配置的发件人显示名) */
templateNickname: string;
/** 最终消息正文(占位符已渲染,直接展示) */
templateContent: string;
/** 消息类型,字典 system_notify_template_type */
templateType: number;
/** 是否已读 */
readStatus: boolean;
/** 阅读时间;未读为 null */
readTime: string | number | null;
/** 收到时间 */
createTime: string | number;
}
/** 我的站内信分页查询参数 */
interface MyPageParams extends PageParams {
/** true 只看已读 / false 只看未读 / 不传 = 全部 */
readStatus?: boolean;
/** 关键字,后端对消息正文模糊匹配;不传或空串 = 不过滤 */
keyword?: string;
}
}
}

View File

@@ -21,10 +21,27 @@ declare namespace Api {
list: T[];
}
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回;与项目域契约同构) */
interface OverviewStatusItem {
statusCode: string;
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
statusName: string;
count: number;
sort: number;
/** 是否终态(状态机 terminal_flag */
terminal: boolean;
/** 是否计入"全部";当前口径无排除项恒为 true产品列表暂无"全部"视图,按同构契约返回) */
includeInAll: boolean;
}
/** 产品入口页概览统计 */
interface ProductOverviewSummary {
/** 产品状态数量映射key 为后端状态编码 */
/** 产品状态数量映射key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
statusCounts: Record<string, number>;
/** "全部"口径总数 = items 各状态 count 之和 */
total: number;
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
items: OverviewStatusItem[];
}
interface Product {
@@ -172,8 +189,10 @@ declare namespace Api {
type ProductSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & {
Pick<Product, 'directionCode' | 'managerUserId'> & {
keyword: string;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
updateTime: string[];
}
>;

View File

@@ -681,10 +681,29 @@ declare namespace Api {
list: T[];
}
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回) */
interface OverviewStatusItem {
statusCode: string;
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
statusName: string;
count: number;
sort: number;
/** 是否终态(状态机 terminal_flag不能用于"全部"排除或左栏分区completed 也可能是终态) */
terminal: boolean;
/** 是否计入"全部";当前口径无排除项恒为 true将来恢复排除项由该字段表达 */
includeInAll: boolean;
}
/** 项目入口页概览统计 */
interface ProjectOverviewSummary {
/** 项目状态数量映射key 为后端状态编码 */
/** 项目状态数量映射key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
statusCounts: Record<string, number>;
/** "全部"口径总数 = items 各状态 count 之和(作废/归档计入) */
total: number;
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
items: OverviewStatusItem[];
/** 游离项目计数 = 所有未挂产品的项目(不按状态过滤),左栏游离入口据此显隐 */
orphanCount?: number;
}
interface Project {
@@ -787,11 +806,75 @@ declare namespace Api {
projectType: string;
productId: string;
managerUserId: string;
statusCode: ProjectStatusCode;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
/** 多值状态筛选(存在时后端优先于单值 statusCode分组页"展开剩余"按"全部"口径传 items 派生的全量编码 */
statusCodes: string[];
/** 仅查游离项目productId 为空);与 productId 互斥,分组页展开游离组剩余时用 */
orphanOnly: boolean;
updateTime: string[];
}
>;
/**
* 项目列表"按产品分组"查询入参GET /project/project/group-page
*
* - pageNo / pageSize 为**产品组维度**分页(一页 M 个产品组),不是项目行分页。
* - statusCode 不传 = "全部"视图后端从状态机推导2026-06-11 口径变更后无排除项,作废/归档计入)。
* - orphanOnly = true 仅返回游离组productId 为空的项目);不可与 productId 同传。
* - topN每组返回项目条数上限后端默认 5范围 1~50超出由页面展开拉取。
*/
type ProjectGroupSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
productId: string;
projectType: string;
/** 状态编码来自状态机overview-summary items 动态下发),不再用前端字面量联合约束 */
statusCode: string;
orphanOnly: boolean;
topN: number;
}
>;
/** 按产品聚合的项目分组 */
interface ProjectGroup {
/** 产品 ID游离组为 null */
productId: string | null;
/** 产品名称;游离组固定为"游离项目" */
productName: string;
/** 产品编码;游离组为 null */
productCode: string | null;
/** 产品方向字典值;游离组为空串 */
directionCode: string;
/** 产品经理用户 ID */
managerUserId: string | null;
/** 产品经理昵称(后端回填;游离组为 null前端 managerLabelMap 兜底) */
managerUserNickname: string | null;
/** 当前筛选口径下组内项目总数 */
projectTotal: number;
/** 组内项目前 topN 条,按最近更新倒序;剩余由页面按 productId/orphanOnly + statusCodes 走 page 接口展开拉取 */
projects: Project[];
/** 组内按项目类型字典 value 的计数(现状按"全部口径"统计;已提需求改为跟随 statusCode 与 projectTotal 同口径,后端落地后更新本注释) */
typeCounts: Record<string, number>;
/** 是否已有主线项目(口径=存在非已取消 cancelled 的主线,已归档/完成也算占坑);前端直接消费、不用 typeCounts 推导 */
hasBaseline: boolean;
/** 是否游离组(未挂产品) */
orphan: boolean;
}
/** 产品分组分页结果 */
interface ProjectGroupPageResult {
/** 当前筛选口径下产品组总数(分页 total含游离组 */
total: number;
/** 当前筛选口径下项目总数(标题 meta 用) */
projectTotal: number;
/** 当前筛选口径下可见产品跨方向数≥2 时前端渲染方向层) */
directionCount: number;
/** 当前筛选口径下游离项目数(标题/分页用);左栏常驻游离计数改用 overview-summary 的 orphanCount 全口径 */
orphanTotal: number;
list: ProjectGroup[];
}
/** 创建/保存项目参数 */
type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & {
projectCode: string | null;

View File

@@ -129,6 +129,8 @@ declare module 'vue' {
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
IconLocalActivity: typeof import('~icons/local/activity')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalCast: typeof import('~icons/local/cast')['default']

20
src/utils/datetime.ts Normal file
View File

@@ -0,0 +1,20 @@
import dayjs from 'dayjs';
/** 相对时间展示:刚刚 / N 分钟前 / N 小时前 / N 天前,超过 7 天回退完整日期 */
export function formatRelativeTime(value: string | number) {
const time = dayjs(value);
if (!time.isValid()) return '';
const now = dayjs();
const diffMinutes = now.diff(time, 'minute');
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
const diffHours = now.diff(time, 'hour');
if (diffHours < 24) return `${diffHours} 小时前`;
const diffDays = now.diff(time, 'day');
if (diffDays < 7) return `${diffDays} 天前`;
return time.format('YYYY-MM-DD HH:mm');
}

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;

View File

@@ -22,5 +22,5 @@
"isolatedModules": true
},
"include": ["./**/*.ts", "./**/*.tsx", "./**/*.vue"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "docs/backup"]
}