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