feat(projects): 微调

This commit is contained in:
2026-06-17 19:27:17 +08:00
parent 1543bf76a9
commit 31344f1d58
18 changed files with 876 additions and 219 deletions

View File

@@ -2,6 +2,8 @@ import { computed, defineComponent, h, ref } from 'vue';
import type { Component, PropType } from 'vue'; import type { Component, PropType } from 'vue';
import { ElButton, ElPopover, ElTooltip } from 'element-plus'; import { ElButton, ElPopover, ElTooltip } from 'element-plus';
import { $t } from '@/locales'; import { $t } from '@/locales';
import IconMdiDotsHorizontal from '~icons/mdi/dots-horizontal';
import IconMdiChevronDown from '~icons/mdi/chevron-down';
export type BusinessTableAction = { export type BusinessTableAction = {
key: string; key: string;
@@ -162,11 +164,11 @@ export default defineComponent({
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
> >
{props.variant === 'icon' ? ( {props.variant === 'icon' ? (
<icon-mdi-dots-horizontal class="business-table-action-icon" /> <IconMdiDotsHorizontal class="business-table-action-icon" />
) : ( ) : (
<span class="inline-flex items-center gap-4px"> <span class="inline-flex items-center gap-4px">
{$t('common.more')} {$t('common.more')}
<icon-mdi-chevron-down class="text-14px" /> <IconMdiChevronDown class="text-14px" />
</span> </span>
)} )}
</ElButton> </ElButton>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ElTag } from 'element-plus';
defineOptions({ name: 'CurrentUserRoleTags' });
interface Props {
/** 当前登录用户在该对象(产品/项目)的角色;无角色为 []。后端只读计算字段,随登录身份变化 */
roles?: Api.Common.CurrentUserRole[] | null;
/** 无业务角色时的占位文案 */
emptyText?: string;
}
const props = withDefaults(defineProps<Props>(), { roles: () => [], emptyText: '--' });
/**
* 角色族按 roleKey 后缀匹配。
*
* 后端 roleKey 为域前缀风格product_manager / project_manager / product_creator /
* project_creator / *_observer 等(见 src/constants/business.ts 的经理 code
* 文档示例里的裸 creator / implicit_observer 不是真实 key故不能按字面量精确匹配。
*/
function isManagerRole(roleKey: string) {
return /manager$/i.test(roleKey);
}
function isCreatorRole(roleKey: string) {
return /creator$/i.test(roleKey);
}
/** 系统隐式角色:创建者 / 隐式观察者,弱化为淡灰标签 */
function isMuted(roleKey: string) {
return isCreatorRole(roleKey) || /observer$/i.test(roleKey) || roleKey.includes('implicit');
}
const items = computed(() => {
const roles = props.roles ?? [];
// 当前用户是产品经理 / 项目经理时,隐藏创建者标签(隐式观察者不受影响)
const hasManager = roles.some(role => isManagerRole(role.roleKey));
const visibleRoles = hasManager ? roles.filter(role => !isCreatorRole(role.roleKey)) : roles;
return visibleRoles.map((role, index) => ({
key: `${role.roleKey}-${index}`,
roleName: role.roleName,
muted: isMuted(role.roleKey)
}));
});
</script>
<template>
<div v-if="items.length" class="current-user-role-tags">
<ElTag
v-for="item in items"
:key="item.key"
size="small"
effect="plain"
round
:type="item.muted ? 'info' : 'primary'"
:class="{ 'current-user-role-tags__muted': item.muted }"
>
{{ item.roleName }}
</ElTag>
</div>
<span v-else class="current-user-role-tags__empty">{{ emptyText }}</span>
</template>
<style lang="scss" scoped>
.current-user-role-tags {
display: flex;
flex-wrap: wrap;
// 列设置 align="center" 只影响文本流flex 容器需显式居中标签
justify-content: center;
gap: 4px;
}
// 隐式角色(创建者 / 隐式观察者)弱化:在灰色 info 标签基础上再降透明度
.current-user-role-tags__muted {
opacity: 0.65;
}
.current-user-role-tags__empty {
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -191,6 +191,6 @@ export async function fetchGetDictDataByCode(code: string) {
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({ return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
...data, ...data,
list: data.list.map(normalizeDictData) list: (data.list ?? []).map(normalizeDictData)
})); }));
} }

View File

@@ -11,9 +11,11 @@ import { normalizeProductMember, normalizeProductSettings } from './product-shar
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`; const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & { type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId' | 'currentUserRoles'> & {
id: string | number; id: string | number;
managerUserId?: string | number | null; managerUserId?: string | number | null;
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
}; };
type ProductPageResponse = Api.Product.PageResult<ProductResponse>; type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
@@ -39,7 +41,8 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
return { return {
...product, ...product,
id: normalizeStringId(product.id), id: normalizeStringId(product.id),
managerUserId: normalizeNullableStringId(product.managerUserId) ?? '' managerUserId: normalizeNullableStringId(product.managerUserId) ?? '',
currentUserRoles: product.currentUserRoles ?? []
}; };
} }

View File

@@ -47,7 +47,14 @@ const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
export type ProjectResponse = Omit< export type ProjectResponse = Omit<
Api.Project.Project, Api.Project.Project,
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate' | 'id'
| 'managerUserId'
| 'productId'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
| 'currentUserRoles'
> & { > & {
id: string | number; id: string | number;
managerUserId?: string | number | null; managerUserId?: string | number | null;
@@ -56,6 +63,8 @@ export type ProjectResponse = Omit<
plannedEndDate?: ProjectLocalDateValue; plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue; actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue; actualEndDate?: ProjectLocalDateValue;
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
}; };
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>; type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
@@ -96,7 +105,8 @@ export function normalizeProject(project: ProjectResponse): Api.Project.Project
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate), plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate), plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(project.actualStartDate), actualStartDate: normalizeProjectLocalDate(project.actualStartDate),
actualEndDate: normalizeProjectLocalDate(project.actualEndDate) actualEndDate: normalizeProjectLocalDate(project.actualEndDate),
currentUserRoles: project.currentUserRoles ?? []
}; };
} }

View File

@@ -141,7 +141,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
window.$notification?.success({ window.$notification?.success({
title: $t('page.login.common.loginSuccess'), title: $t('page.login.common.loginSuccess'),
message: $t('page.login.common.welcomeBack', { userName: userInfo.userName }), message: $t('page.login.common.welcomeBack', { userName: userInfo.nickname || userInfo.userName }),
duration: 4500 duration: 4500
}); });
} }

View File

@@ -31,6 +31,22 @@ declare namespace Api {
*/ */
type EnableStatus = '1' | '2'; type EnableStatus = '1' | '2';
/**
* 列表项「当前登录用户在该对象的角色」(产品 / 项目列表共用)。
*
* 后端只读计算字段,随登录身份变化:同一份列表不同账号看到的内容不同;无角色为 []。
* 提交 / 更新接口不需要回传它。
*/
interface CurrentUserRole {
/**
* 角色稳定标识(程序判断用,不随中文名变化)。
* 例product_manager / project_manager / developer / tester / watcher / creator / implicit_observer。
*/
roleKey: string;
/** 角色中文名(直接展示) */
roleName: string;
}
/** common record */ /** common record */
type CommonRecord<T = any> = { type CommonRecord<T = any> = {
/** record id */ /** record id */

View File

@@ -67,6 +67,8 @@ declare namespace Api {
createTime: string; createTime: string;
/** 更新时间 */ /** 更新时间 */
updateTime: string; updateTime: string;
/** 当前登录用户在该产品的角色(后端只读计算字段,随登录身份变化;无角色为 [] */
currentUserRoles: Api.Common.CurrentUserRole[];
} }
interface ProductSettingBaseInfo { interface ProductSettingBaseInfo {

View File

@@ -851,6 +851,8 @@ declare namespace Api {
createTime: string; createTime: string;
/** 更新时间 */ /** 更新时间 */
updateTime: string; updateTime: string;
/** 当前登录用户在该项目的角色(后端只读计算字段,随登录身份变化;无角色为 [] */
currentUserRoles: Api.Common.CurrentUserRole[];
} }
interface ProjectContext { interface ProjectContext {

View File

@@ -23,6 +23,7 @@ declare module 'vue' {
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default'] BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default'] ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default'] CountTo: typeof import('./../components/custom/count-to.vue')['default']
CurrentUserRoleTags: typeof import('./../components/custom/current-user-role-tags.vue')['default']
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default'] CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default'] DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
DictSelect: typeof import('./../components/custom/dict-select.vue')['default'] DictSelect: typeof import('./../components/custom/dict-select.vue')['default']
@@ -125,9 +126,13 @@ declare module 'vue' {
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default'] IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default'] IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default'] IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IconIcRoundFolder: typeof import('~icons/ic/round-folder')['default']
IconIcRoundInventory2: typeof import('~icons/ic/round-inventory2')['default']
IconIcRoundPackage2: typeof import('~icons/ic/round-package2')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default'] IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default'] IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default'] IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundRocketLaunch: typeof import('~icons/ic/round-rocket-launch')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default'] IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default'] IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default'] IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
@@ -137,6 +142,7 @@ declare module 'vue' {
IconLocalLogo: typeof import('~icons/local/logo')['default'] IconLocalLogo: typeof import('~icons/local/logo')['default']
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default'] 'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default']
IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default'] IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default']
IconMaterialSymbolsPackage2: typeof import('~icons/material-symbols/package2')['default']
'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default'] 'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default']
'IconMdi:printer': typeof import('~icons/mdi/printer')['default'] 'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default'] IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']

View File

@@ -148,29 +148,29 @@ const waves = [
/** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */ /** 电力场景剪影:输电铁塔(底部接地,局部坐标基点为塔脚中心) */
const towers = [ const towers = [
{ x: 150, s: 1 }, { x: 150, s: 1.42 },
{ x: 540, s: 0.85 }, { x: 540, s: 1.2 },
{ x: 1280, s: 0.7 } { x: 1280, s: 1.0 }
]; ];
/** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点 */ /** 塔间悬垂导线(悬链线意象),坐标对应各塔最宽横担端点(随铁塔 scale 同步重算) */
const powerLines = [ const powerLines = [
{ path: 'M -60,762 Q 30,800 92,750' }, { path: 'M -60,712 Q 4,756 68,700' },
{ path: 'M 208,750 Q 350,819 491,768' }, { path: 'M 232,700 Q 350,777 470,726' },
{ path: 'M 589,768 Q 914,867 1239,786' }, { path: 'M 610,726 Q 915,831 1222,750' },
{ path: 'M 1321,786 Q 1430,824 1520,804' } { path: 'M 1338,750 Q 1429,788 1520,768' }
]; ];
/** 导线上滑过的电流光点 */ /** 导线上滑过的电流光点(端点对齐 powerLines 的中间两段) */
const lineSparks = [ const lineSparks = [
{ key: 'spark-1', path: 'M 208,750 Q 350,819 491,768', dur: '5s', begin: '0s' }, { key: 'spark-1', path: 'M 232,700 Q 350,777 470,726', dur: '5s', begin: '0s' },
{ key: 'spark-2', path: 'M 589,768 Q 914,867 1239,786', dur: '7s', begin: '2s' } { key: 'spark-2', path: 'M 610,726 Q 915,831 1222,750', dur: '7s', begin: '2s' }
]; ];
/** 风机新能源应用场景dur 为叶轮旋转周期 */ /** 风机新能源应用场景dur 为叶轮旋转周期 */
const turbines = [ const turbines = [
{ key: 'turbine-1', x: 715, s: 0.9, dur: '9s' }, { key: 'turbine-1', x: 715, s: 1.28, dur: '9s' },
{ key: 'turbine-2', x: 828, s: 0.6, dur: '13s' } { key: 'turbine-2', x: 828, s: 0.88, dur: '13s' }
]; ];
</script> </script>

View File

@@ -1,16 +1,13 @@
<script setup lang="tsx"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import type { Component } from 'vue'; import type { Component } from 'vue';
import { ElButton, ElTag } from 'element-plus'; import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { Box, DeleteFilled, Document, Menu, 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 { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api'; import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data'; import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
import ProductGroupedTable from './modules/product-grouped-table.vue';
import ProductOperateDialog from './modules/product-operate-dialog.vue'; import ProductOperateDialog from './modules/product-operate-dialog.vue';
import ProductSearch from './modules/product-search.vue'; import ProductSearch from './modules/product-search.vue';
@@ -23,14 +20,12 @@ interface StatusVisualMeta {
icon: Component; icon: Component;
} }
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list'; const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams { function getInitSearchParams(): Api.Product.ProductSearchParams {
return { return {
pageNo: 1, pageNo: 1,
pageSize: 20, pageSize: -1,
keyword: '', keyword: '',
directionCode: undefined, directionCode: undefined,
managerUserId: undefined, managerUserId: undefined,
@@ -39,36 +34,10 @@ function getInitSearchParams(): Api.Product.ProductSearchParams {
}; };
} }
function transformProductPage(response: ProductPageResponse, 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[]) { function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN')); 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');
}
/** 状态视觉资产icon/tone是前端本地映射状态名直接渲染后端 statusName不做本地名称映射 */ /** 状态视觉资产icon/tone是前端本地映射状态名直接渲染后端 statusName不做本地名称映射 */
const STATUS_VISUALS: Record<string, StatusVisualMeta> = { const STATUS_VISUALS: Record<string, StatusVisualMeta> = {
active: { tone: 'teal', icon: VideoPlay }, active: { tone: 'teal', icon: VideoPlay },
@@ -89,7 +58,11 @@ const operateVisible = ref(false);
const editingRow = ref<Api.Product.Product | null>(null); const editingRow = ref<Api.Product.Product | null>(null);
const { routerPush } = useRouterPush(); const { routerPush } = useRouterPush();
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE); /** 当前筛选口径下的全量产品pageSize=-1 一次拉全),分组 / 收纳 / 假分页全在 ProductGroupedTable 内 */
const allProducts = ref<Api.Product.Product[]>([]);
const loading = ref(false);
/** 折叠全部开关true = 全部方向折叠);仅多方向视图有意义 */
const allCollapsed = ref(false);
/** 状态看板项overview-summary items状态机动态下发已按 sort 升序) */ /** 状态看板项overview-summary items状态机动态下发已按 sort 升序) */
const statusBoardItems = ref<Api.Product.OverviewStatusItem[]>([]); const statusBoardItems = ref<Api.Product.OverviewStatusItem[]>([]);
@@ -100,6 +73,10 @@ const managerLabelMap = computed(() => {
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname])); return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
}); });
/** 当前筛选口径下可见产品跨方向数≥2 时渲染方向分层,否则单方向平铺 */
const directionCount = computed(() => new Set(allProducts.value.map(item => item.directionCode || '')).size);
const showDirectionLayer = computed(() => directionCount.value >= 2);
const statusItems = computed(() => [ const statusItems = computed(() => [
{ key: 'all', label: '全部产品', count: statusBoardTotal.value, tone: 'sky' as StatusNavTone, icon: Menu }, { key: 'all', label: '全部产品', count: statusBoardTotal.value, tone: 'sky' as StatusNavTone, icon: Menu },
...statusBoardItems.value.map(item => { ...statusBoardItems.value.map(item => {
@@ -115,92 +92,29 @@ const statusItems = computed(() => [
}) })
]); ]);
function getDirectionLabel(directionCode?: string | null) {
return getDirectionDictLabel(directionCode, '--');
}
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function createRequestParams(): Api.Product.ProductSearchParams { function createRequestParams(): Api.Product.ProductSearchParams {
return { return {
...searchParams, ...searchParams,
pageNo: 1,
pageSize: -1,
keyword: searchParams.keyword?.trim() || undefined, keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value === 'all' ? undefined : selectedStatus.value statusCode: selectedStatus.value === 'all' ? undefined : selectedStatus.value
}; };
} }
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable< async function loadProducts() {
ProductPageResponse, loading.value = true;
Api.Product.Product
>({ try {
paginationProps: { const { error, data } = await fetchGetProductPage(createRequestParams());
currentPage: searchParams.pageNo, allProducts.value = !error && data ? data.list : [];
pageSize: searchParams.pageSize } catch {
}, allProducts.value = [];
api: () => fetchGetProductPage(createRequestParams()), window.$message?.error('产品列表加载失败');
transform: response => transformProductPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10), } finally {
onPaginationParamsChange: params => { loading.value = false;
searchParams.pageNo = params.currentPage ?? 1; }
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'name',
label: '产品名称',
minWidth: 220,
formatter: row => (
<ElButton link type="primary" class="product-name-link" onClick={() => enterProductContext(row)}>
{row.name}
</ElButton>
)
},
{ prop: 'code', label: '产品编码', minWidth: 140, showOverflowTooltip: true },
{
prop: 'managerUserId',
label: '产品经理',
minWidth: 120,
formatter: row => getManagerLabel(row.managerUserId)
},
{
prop: 'directionCode',
label: '产品方向',
minWidth: 140,
showOverflowTooltip: true,
formatter: row => getDirectionLabel(row.directionCode)
},
{
prop: 'statusCode',
label: '管理状态',
width: 120,
align: 'center',
formatter: row => (
<ElTag type={getProductStatusTagType(row.statusCode)}>{getProductStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'lastStatusReason',
label: '状态原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: row => row.lastStatusReason?.trim() || '--'
},
{
prop: 'updateTime',
label: '最近更新',
width: 170,
align: 'center',
formatter: row => formatDate(row.updateTime)
} }
],
immediate: false
});
async function loadManagerOptions() { async function loadManagerOptions() {
const { error, data: userList } = await fetchGetUserSimpleList(); const { error, data: userList } = await fetchGetUserSimpleList();
@@ -223,31 +137,23 @@ async function loadOverviewData() {
statusBoardTotal.value = error || !overviewSummary ? 0 : overviewSummary.total; statusBoardTotal.value = error || !overviewSummary ? 0 : overviewSummary.total;
} }
async function reloadProductTable(page = searchParams.pageNo ?? 1) { async function refreshPageData() {
await getDataByPage(page); await Promise.all([loadManagerOptions(), loadOverviewData(), loadProducts()]);
}
async function refreshPageData(page = searchParams.pageNo ?? 1) {
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProductTable(page)]);
} }
async function handleSearch() { async function handleSearch() {
await reloadProductTable(1); await loadProducts();
} }
async function handleResetSearch() { async function handleResetSearch() {
const pageSize = searchParams.pageSize ?? 10; Object.assign(searchParams, getInitSearchParams());
await loadProducts();
Object.assign(searchParams, getInitSearchParams(), {
pageSize
});
await reloadProductTable(1);
} }
async function handleStatusChange(status: string) { async function handleStatusChange(status: string) {
selectedStatus.value = status; selectedStatus.value = status;
await Promise.all([loadOverviewData(), reloadProductTable(1)]); allCollapsed.value = false;
await Promise.all([loadOverviewData(), loadProducts()]);
} }
function openCreate() { function openCreate() {
@@ -267,7 +173,7 @@ async function enterProductContext(row: Api.Product.Product) {
async function handleProductSubmitted(productId?: string) { async function handleProductSubmitted(productId?: string) {
const isEditing = Boolean(productId && editingRow.value?.id === productId); const isEditing = Boolean(productId && editingRow.value?.id === productId);
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1); await refreshPageData();
if (isEditing) { if (isEditing) {
editingRow.value = null; editingRow.value = null;
@@ -319,7 +225,7 @@ onMounted(async () => {
@search="handleSearch" @search="handleSearch"
/> />
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="product-table-card-body"> <ElCard class="card-wrapper xl:flex-1-hidden" body-class="business-table-card-body">
<template #header> <template #header>
<div class="product-card-header"> <div class="product-card-header">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -331,43 +237,45 @@ onMounted(async () => {
getProductStatusLabel(selectedStatus) getProductStatusLabel(selectedStatus)
}} }}
</ElTag> </ElTag>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag> <ElTag effect="plain">{{ allProducts.length }}</ElTag>
</div> </div>
</div> </div>
<TableHeaderOperation <div class="flex flex-none items-center gap-8px">
v-model:columns="columnChecks" <ElButton
:disabled-delete="true" plain
:loading="loading" :disabled="!showDirectionLayer || !allProducts.length"
@refresh="refreshPageData" @click="allCollapsed = !allCollapsed"
> >
<template #default> <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 :loading="loading" @click="refreshPageData">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
刷新
</ElButton>
<ElButton plain type="primary" @click="openCreate"> <ElButton plain type="primary" @click="openCreate">
<template #icon> <template #icon>
<icon-ic-round-plus class="text-icon" /> <icon-ic-round-plus class="text-icon" />
</template> </template>
新增 新增
</ElButton> </ElButton>
</template> </div>
</TableHeaderOperation>
</div> </div>
</template> </template>
<div class="flex-1"> <div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data"> <ProductGroupedTable
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" /> :products="allProducts"
:loading="loading"
<template #empty> :show-direction-layer="showDirectionLayer"
<ElEmpty description="当前筛选条件下暂无产品" /> :all-collapsed="allCollapsed"
</template> :manager-label-map="managerLabelMap"
</ElTable> @enter="enterProductContext"
</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']"
/> />
</div> </div>
</ElCard> </ElCard>
@@ -483,12 +391,6 @@ onMounted(async () => {
color: rgb(225 29 72 / 92%); color: rgb(225 29 72 / 92%);
} }
:deep(.product-table-card-body) {
display: flex;
flex: 1;
flex-direction: column;
}
.product-card-header { .product-card-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -496,10 +398,6 @@ onMounted(async () => {
gap: 12px; gap: 12px;
} }
.product-name-link {
padding: 0;
}
@media (width <= 1280px) { @media (width <= 1280px) {
.product-card-header, .product-card-header,
.product-status-item__top { .product-status-item__top {

View File

@@ -0,0 +1,580 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ElButton, ElEmpty, ElIcon, ElPagination, ElTable, ElTableColumn, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { ArrowDown } from '@element-plus/icons-vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import CurrentUserRoleTags from '@/components/custom/current-user-role-tags.vue';
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
defineOptions({ name: 'ProductGroupedTable' });
interface Props {
/** 当前筛选口径下的全量产品(前端分组 / 收纳 / 假分页都基于它) */
products: Api.Product.Product[];
loading?: boolean;
/** 是否渲染方向小节层(可见方向数 ≥2false = 单方向平铺 */
showDirectionLayer: boolean;
/** 折叠全部开关true = 全部方向折叠,仅保留方向头) */
allCollapsed: boolean;
/** userId -> 昵称,产品经理列兜底回显 */
managerLabelMap: Map<string, string>;
}
const props = withDefaults(defineProps<Props>(), { loading: false });
interface Emits {
(e: 'enter', product: Api.Product.Product): void;
}
const emit = defineEmits<Emits>();
const { getLabel: getDirectionLabel, dictOptions: directionOptions } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
/** 方向内默认直出条数(超出收纳为「还有 X 个」) */
const DEFAULT_TOP_N = 5;
/** 展开后方向内每页条数(超出 1 页才出分页器) */
const PAGE_SIZE = 10;
/** 产品行多列数(名称/编码/经理/我的角色/状态/原因/更新),非产品行整行合并用 */
const COLUMN_COUNT = 7;
/** 产品描述副行最大展示字符数,超出截断并追加 …(完整内容走 title 悬浮) */
const PRODUCT_DESC_MAX_LEN = 48;
interface DirectionGroup {
directionCode: string;
products: Api.Product.Product[];
}
/** updateTime 后端实际可能为时间戳(number)或字符串,统一转毫秒数比较;缺失/非法兜底 0 */
function updateTimeValue(value: Api.Product.Product['updateTime']) {
const time = dayjs(value ?? 0).valueOf();
return Number.isFinite(time) ? time : 0;
}
/** 按方向分组:方向顺序按字典 options未知方向落最后组内按最近更新倒序 */
const groups = computed<DirectionGroup[]>(() => {
const map = new Map<string, Api.Product.Product[]>();
for (const product of props.products) {
const code = product.directionCode || '';
const list = map.get(code);
if (list) {
list.push(product);
} else {
map.set(code, [product]);
}
}
for (const list of map.values()) {
list.sort((left, right) => updateTimeValue(right.updateTime) - updateTimeValue(left.updateTime));
}
const orderIndex = new Map(directionOptions.value.map((option, index): [string, number] => [option.value, index]));
return [...map.entries()]
.map(([directionCode, list]) => ({ directionCode, products: list }))
.sort((left, right) => {
const leftOrder = orderIndex.has(left.directionCode)
? orderIndex.get(left.directionCode)!
: Number.MAX_SAFE_INTEGER;
const rightOrder = orderIndex.has(right.directionCode)
? orderIndex.get(right.directionCode)!
: Number.MAX_SAFE_INTEGER;
return leftOrder - rightOrder;
});
});
// === 方向折叠态(折叠 = 隐藏该方向下全部产品;与 top-N 收纳相互独立,对齐项目列表) ===
/** 已折叠方向(按 directionCode */
const collapsedDirections = ref(new Set<string>());
function collapseAllDirections() {
return new Set(groups.value.map(group => group.directionCode));
}
function toggleDirection(code: string) {
const next = new Set(collapsedDirections.value);
if (next.has(code)) {
next.delete(code);
} else {
next.add(code);
}
collapsedDirections.value = next;
}
// === 收纳 / 方向内假分页态(数据刷新即重置) ===
/** 已展开方向(多方向:从前 5 展开到分页态);单方向恒为展开 */
const expandedDirections = ref(new Set<string>());
/** 方向内当前页码directionCode -> page默认 1 */
const directionPages = ref(new Map<string, number>());
watch(
() => props.products,
() => {
expandedDirections.value = new Set();
directionPages.value = new Map();
collapsedDirections.value = props.allCollapsed ? collapseAllDirections() : new Set();
}
);
watch(
() => props.allCollapsed,
value => {
collapsedDirections.value = value ? collapseAllDirections() : new Set();
}
);
/** 单方向直接进入展开(分页)态,无 5 档收纳 */
function isExpanded(code: string) {
return !props.showDirectionLayer || expandedDirections.value.has(code);
}
function getPage(code: string) {
return directionPages.value.get(code) ?? 1;
}
/** 当前方向应渲染的产品:未展开取前 N展开且 >1 页取当前页切片,否则全量 */
function visibleProducts(group: DirectionGroup) {
if (!isExpanded(group.directionCode)) {
return group.products.slice(0, DEFAULT_TOP_N);
}
if (group.products.length > PAGE_SIZE) {
const start = (getPage(group.directionCode) - 1) * PAGE_SIZE;
return group.products.slice(start, start + PAGE_SIZE);
}
return group.products;
}
function toggleExpand(code: string) {
const next = new Set(expandedDirections.value);
if (next.has(code)) {
next.delete(code);
const pages = new Map(directionPages.value);
pages.delete(code);
directionPages.value = pages;
} else {
next.add(code);
}
expandedDirections.value = next;
}
function changePage(code: string, page: number) {
const pages = new Map(directionPages.value);
pages.set(code, page);
directionPages.value = pages;
}
// === 扁平行模型:分组结构铺平后交给 ElTable非产品行整行合并单元格 ===
type FlatRowType = 'dir' | 'product' | 'more' | 'pager';
interface FlatRow {
rowType: FlatRowType;
key: string;
group?: DirectionGroup;
product?: Api.Product.Product;
}
const flatRows = computed<FlatRow[]>(() => {
const rows: FlatRow[] = [];
for (const group of groups.value) {
const code = group.directionCode || 'unknown';
const total = group.products.length;
// 方向折叠:仅保留方向头,隐藏其下产品 / 收纳 / 分页行
const collapsed = props.showDirectionLayer && collapsedDirections.value.has(group.directionCode);
if (props.showDirectionLayer) {
rows.push({ rowType: 'dir', key: `dir-${code}`, group });
}
if (!collapsed) {
for (const product of visibleProducts(group)) {
rows.push({ rowType: 'product', key: `prod-${product.id}`, group, product });
}
if (isExpanded(group.directionCode) && total > PAGE_SIZE) {
rows.push({ rowType: 'pager', key: `pager-${code}`, group });
}
// 收起/展开切换行仅多方向出现(单方向无 5 档收纳)
if (props.showDirectionLayer && total > DEFAULT_TOP_N) {
rows.push({ rowType: 'more', key: `more-${code}`, group });
}
}
}
return rows;
});
function getRowKey(row: FlatRow) {
return row.key;
}
function spanMethod({ row, columnIndex }: { row: FlatRow; columnIndex: number }) {
if (row.rowType === 'product') {
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.group ? collapsedDirections.value.has(row.group.directionCode) : false;
return `pg-dir-row${collapsed ? ' is-collapsed' : ''}`;
}
if (row.rowType === 'more') {
return 'pg-more-row';
}
if (row.rowType === 'pager') {
return 'pg-pager-row';
}
return 'pg-prod-row';
}
function handleRowClick(row: FlatRow) {
if (row.rowType === 'dir' && row.group) {
toggleDirection(row.group.directionCode);
return;
}
if (row.rowType === 'more' && row.group) {
toggleExpand(row.group.directionCode);
}
}
// === 回显 ===
function getManagerName(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return props.managerLabelMap.get(String(managerUserId)) || String(managerUserId);
}
function formatDate(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD');
}
/** 产品描述副行:控长截断,超出追加 …;完整内容由 title 悬浮展示 */
function truncateDesc(text?: string | null) {
const trimmed = (text ?? '').trim();
return trimmed.length > PRODUCT_DESC_MAX_LEN ? `${trimmed.slice(0, PRODUCT_DESC_MAX_LEN)}` : trimmed;
}
/** 描述是否被截断(放不下);仅截断时才追加「详情」入口 */
function isDescTruncated(text?: string | null) {
return (text ?? '').trim().length > PRODUCT_DESC_MAX_LEN;
}
</script>
<template>
<ElTable
v-loading="loading"
class="product-grouped-table"
height="100%"
:data="flatRows"
:row-key="getRowKey"
:span-method="spanMethod"
:row-class-name="rowClassName"
@row-click="handleRowClick"
>
<ElTableColumn label="产品名称" min-width="240" 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.group.directionCode, row.group.directionCode || '--') }}
</span>
<span class="pg-dir-meta">{{ row.group.products.length }} 个产品</span>
</div>
<div v-else-if="row.rowType === 'product'" class="pg-prod-name">
<span class="pg-prod-icon">
<icon-material-symbols-package-2 />
</span>
<div class="pg-prod-main">
<ElButton link type="primary" class="pg-prod-link" @click="emit('enter', row.product)">
{{ row.product.name }}
</ElButton>
<div
v-if="row.product.description?.trim()"
class="pg-prod-desc"
:title="row.product.description"
@click="emit('enter', row.product)"
>
<span class="pg-prod-desc__text">{{ truncateDesc(row.product.description) }}</span>
<span v-if="isDescTruncated(row.product.description)" class="pg-prod-desc__more">详情</span>
</div>
</div>
</div>
<div v-else-if="row.rowType === 'more'" class="pg-more-line">
<span class="pg-more-link">
<ElIcon class="pg-more-icon" :class="{ 'pg-more-icon--up': isExpanded(row.group.directionCode) }">
<ArrowDown />
</ElIcon>
<template v-if="isExpanded(row.group.directionCode)">收起</template>
<template v-else>还有 {{ row.group.products.length - DEFAULT_TOP_N }} 展开查看</template>
</span>
<span v-if="!isExpanded(row.group.directionCode)" class="pg-more-hint">
默认显示前 {{ DEFAULT_TOP_N }} 按最近更新排序
</span>
</div>
<div v-else-if="row.rowType === 'pager'" class="pg-pager-line" @click.stop>
<ElPagination
small
layout="total, prev, pager, next"
:total="row.group.products.length"
:page-size="PAGE_SIZE"
:current-page="getPage(row.group.directionCode)"
@current-change="page => changePage(row.group.directionCode, page)"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="产品编码" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row.rowType === 'product'">{{ row.product.code || '--' }}</template>
</template>
</ElTableColumn>
<ElTableColumn label="产品经理" width="120">
<template #default="{ row }">
<template v-if="row.rowType === 'product'">{{ getManagerName(row.product.managerUserId) }}</template>
</template>
</ElTableColumn>
<ElTableColumn label="我的身份" min-width="140" align="center">
<template #default="{ row }">
<CurrentUserRoleTags v-if="row.rowType === 'product'" :roles="row.product.currentUserRoles" />
</template>
</ElTableColumn>
<ElTableColumn label="管理状态" width="120" align="center">
<template #default="{ row }">
<ElTag v-if="row.rowType === 'product'" :type="getProductStatusTagType(row.product.statusCode)">
{{ getProductStatusLabel(row.product.statusCode) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态原因" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row.rowType === 'product'">{{ row.product.lastStatusReason?.trim() || '--' }}</template>
</template>
</ElTableColumn>
<ElTableColumn label="最近更新" width="120" align="center">
<template #default="{ row }">
<span v-if="row.rowType === 'product'" class="pg-muted">{{ formatDate(row.product.updateTime) }}</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="当前筛选条件下暂无产品" />
</template>
</ElTable>
</template>
<style lang="scss" scoped>
.product-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;
}
:deep(.pg-prod-row > td.el-table__cell) {
border-bottom: 1px solid var(--el-border-color-extra-light);
}
:deep(.pg-more-row > td.el-table__cell),
:deep(.pg-pager-row > td.el-table__cell) {
padding: 6px 0;
background: transparent;
}
:deep(.pg-more-row > td.el-table__cell) {
cursor: pointer;
}
}
// === 方向节标题 ===
.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-name {
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-main {
display: flex;
flex-direction: column;
// 贴左:避免 ElButton(link) 被 stretch 拉满列宽后文字居中
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.pg-prod-link {
padding: 0;
}
// 描述副行:控长截断 + 末尾「详情」链接,整行可点(进入对象域)
.pg-prod-desc {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
min-width: 0;
cursor: pointer;
}
.pg-prod-desc__text {
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.pg-prod-desc__more {
flex: none;
color: var(--el-color-primary);
font-size: 12px;
}
.pg-prod-desc:hover .pg-prod-desc__more {
text-decoration: underline;
}
.pg-muted {
color: var(--el-text-color-secondary);
}
// === 收起/展开行 ===
.pg-more-line {
display: flex;
align-items: center;
gap: 8px;
margin-left: 34px;
}
.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;
transition: transform 0.2s ease;
}
.pg-more-icon--up {
transform: rotate(180deg);
}
.pg-more-hint {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
// === 方向内分页行 ===
.pg-pager-line {
display: flex;
justify-content: flex-end;
margin-left: 34px;
}
</style>

View File

@@ -2,9 +2,10 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { ElButton, ElEmpty, ElProgress, ElTag, ElTooltip } from 'element-plus'; import { ElButton, ElEmpty, ElProgress, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { ArrowDown, Collection, QuestionFilled } from '@element-plus/icons-vue'; import { ArrowDown, QuestionFilled } from '@element-plus/icons-vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict'; import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict'; import { useDict } from '@/hooks/business/dict';
import CurrentUserRoleTags from '@/components/custom/current-user-role-tags.vue';
import { getProjectStatusLabel, getProjectStatusTagType } from '../../shared/project-master-data'; import { getProjectStatusLabel, getProjectStatusTagType } from '../../shared/project-master-data';
defineOptions({ name: 'ProjectGroupedTable' }); defineOptions({ name: 'ProjectGroupedTable' });
@@ -202,7 +203,8 @@ interface FlatRow {
project?: Api.Project.Project; project?: Api.Project.Project;
} }
const COLUMN_COUNT = 7; // 项目行列数(名称/类型/经理/我的角色/进度/计划周期/状态/更新),非项目行整行合并用
const COLUMN_COUNT = 8;
/** 单个产品组铺平为行:产品行 +(未折叠时)占位/项目/收纳行 */ /** 单个产品组铺平为行:产品行 +(未折叠时)占位/项目/收纳行 */
function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] { function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
@@ -218,7 +220,12 @@ function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
} }
for (const project of visibleProjects(group)) { for (const project of visibleProjects(group)) {
rows.push({ rowType: 'project', key: `proj-${project.id}`, group, project }); rows.push({
rowType: 'project',
key: `proj-${project.id}`,
group,
project
});
} }
if (hiddenCount(group) > 0) { if (hiddenCount(group) > 0) {
@@ -396,10 +403,8 @@ function overdueDays(project: Api.Project.Project) {
<div v-else-if="row.rowType === 'product'" class="pg-prod-line"> <div v-else-if="row.rowType === 'product'" class="pg-prod-line">
<ElIcon class="pg-toggle"><ArrowDown /></ElIcon> <ElIcon class="pg-toggle"><ArrowDown /></ElIcon>
<span class="pg-prod-icon" :class="{ 'pg-prod-icon--orphan': row.group.orphan }"> <span class="pg-prod-icon" :class="{ 'pg-prod-icon--orphan': row.group.orphan }">
<ElIcon> <ElIcon v-if="row.group.orphan"><QuestionFilled /></ElIcon>
<QuestionFilled v-if="row.group.orphan" /> <icon-material-symbols-package-2 v-else />
<Collection v-else />
</ElIcon>
</span> </span>
<span class="pg-prod-name" :class="{ 'pg-prod-name--orphan': row.group.orphan }"> <span class="pg-prod-name" :class="{ 'pg-prod-name--orphan': row.group.orphan }">
{{ row.group.productName }} {{ row.group.productName }}
@@ -429,11 +434,16 @@ function overdueDays(project: Api.Project.Project) {
</div> </div>
<div v-else-if="row.rowType === 'project'" class="pg-proj-name"> <div v-else-if="row.rowType === 'project'" class="pg-proj-name">
<span class="pg-proj-icon">
<icon-ic-round-folder />
</span>
<div class="pg-proj-main">
<ElButton link type="primary" class="pg-proj-link" @click="emit('enter', row.project)"> <ElButton link type="primary" class="pg-proj-link" @click="emit('enter', row.project)">
{{ row.project.projectName }} {{ row.project.projectName }}
</ElButton> </ElButton>
<div class="pg-sub-code">{{ row.project.projectCode }}</div> <div class="pg-sub-code">{{ row.project.projectCode }}</div>
</div> </div>
</div>
<div v-else class="pg-more-line"> <div v-else class="pg-more-line">
<template v-if="expandingKeys.has(groupKey(row.group))"> <template v-if="expandingKeys.has(groupKey(row.group))">
@@ -454,18 +464,24 @@ function overdueDays(project: Api.Project.Project) {
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="项目类型" width="110" align="left" show-overflow-tooltip> <ElTableColumn label="项目类型" width="110" align="center" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.rowType === 'project'">{{ getTypeLabel(row.project.projectType, '--') }}</template> <template v-if="row.rowType === 'project'">{{ getTypeLabel(row.project.projectType, '--') }}</template>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="项目经理" width="100" align="left" show-overflow-tooltip> <ElTableColumn label="项目经理" width="100" align="center" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.rowType === 'project'">{{ projectManagerLabel(row.project) }}</template> <template v-if="row.rowType === 'project'">{{ projectManagerLabel(row.project) }}</template>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="我的身份" min-width="130" align="center">
<template #default="{ row }">
<CurrentUserRoleTags v-if="row.rowType === 'project'" :roles="row.project.currentUserRoles" />
</template>
</ElTableColumn>
<ElTableColumn label="进度" min-width="200"> <ElTableColumn label="进度" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row.rowType === 'project'" class="pg-progress"> <div v-if="row.rowType === 'project'" class="pg-progress">
@@ -660,13 +676,44 @@ function overdueDays(project: Api.Project.Project) {
padding: 0; padding: 0;
} }
// === 提示行 / 项目行 / 收纳行:统一缩进到产品名起始位 === // === 提示行 / 收纳行:统一缩进到产品名起始位 ===
.pg-hint, .pg-hint,
.pg-more-line, .pg-more-line {
.pg-proj-name {
margin-left: 55px; margin-left: 55px;
} }
// 项目行:文件夹图标 + 文本块图标占缩进位margin 25 + icon22 + gap8 = 55项目名仍与产品名对齐
.pg-proj-name {
display: flex;
align-items: center;
gap: 8px;
margin-left: 25px;
min-width: 0;
}
// 项目文件夹图标:浅蓝底 + 同色系深字,与产品库存箱图标同一视觉语言(父=箱子 / 子=文件夹)
.pg-proj-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 22px;
height: 22px;
border-radius: 6px;
background: rgb(240 249 255 / 96%);
color: rgb(2 132 199 / 92%);
font-size: 14px;
}
.pg-proj-main {
display: flex;
flex-direction: column;
// 贴左:避免 ElButton(link) 被 stretch 拉满后文字居中
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.pg-hint { .pg-hint {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -16,7 +16,7 @@ export interface ViewContext {
} }
export function useTaskViewContext() { export function useTaskViewContext() {
const state = reactive<ViewContext>({ type: 'my' }); const state = reactive<ViewContext>({ type: 'all' });
function switchToMine() { function switchToMine() {
state.type = 'my'; state.type = 'my';

View File

@@ -64,7 +64,7 @@ const { context: viewContext, switchToMine, switchToAll } = useTaskViewContext()
// 执行域独立视角:跟任务域 viewContext 完全独立,切换互不影响。 // 执行域独立视角:跟任务域 viewContext 完全独立,切换互不影响。
// 用 inline reactive 即可,不抽 composable(只在本页用,YAGNI)。 // 用 inline reactive 即可,不抽 composable(只在本页用,YAGNI)。
const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'my' }); const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'all' });
function switchExecutionToMine() { function switchExecutionToMine() {
executionViewContext.type = 'my'; executionViewContext.type = 'my';
@@ -406,8 +406,8 @@ async function handleExecutionStatusFilter(status: ExecutionStatusFilter) {
selectedStatus.value = status; selectedStatus.value = status;
// 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution // 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution
if (selectedExecution.value) selectedExecution.value = null; if (selectedExecution.value) selectedExecution.value = null;
// 左侧范围切换默认回「我参与的」视角,符合"在该范围内看自己的"主用法 // 左侧范围切换默认回「所有任务」视角,符合"在该范围内看全部"的主用法
switchToMine(); switchToAll();
await reloadExecutionData(1); await reloadExecutionData(1);
} }
@@ -437,9 +437,9 @@ async function getExecutionDetail(row: Api.Project.ProjectExecution) {
} }
function handleSelectExecution(row: Api.Project.ProjectExecution) { function handleSelectExecution(row: Api.Project.ProjectExecution) {
// 锚定具体执行,默认回「我参与的」视角 // 锚定具体执行,默认回「所有任务」视角
selectedExecution.value = row; selectedExecution.value = row;
switchToMine(); switchToAll();
} }
function handleSelectPerspective(type: 'my' | 'all') { function handleSelectPerspective(type: 'my' | 'all') {

View File

@@ -377,11 +377,11 @@ async function loadFormOptions() {
} }
if (!companyResult.error) { if (!companyResult.error) {
companyOptions.value = companyResult.data; companyOptions.value = companyResult.data.list;
} }
if (!orgCodeResult.error) { if (!orgCodeResult.error) {
orgCodeOptions.value = orgCodeResult.data; orgCodeOptions.value = orgCodeResult.data.list;
} }
} }

View File

@@ -359,7 +359,9 @@ watch(activeTab, async tab => {
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" /> <SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
<span>每日工时</span> <span>每日工时</span>
<ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top"> <ElTooltip content="系统按填报日期段均摊到工作日的推算值(周末份额计入周五),非逐日实填" placement="top">
<span class="ww-section-info-wrap">
<SvgIcon icon="mdi:information-outline" class="ww-section-info" /> <SvgIcon icon="mdi:information-outline" class="ww-section-info" />
</span>
</ElTooltip> </ElTooltip>
</div> </div>
</div> </div>
@@ -535,11 +537,16 @@ watch(activeTab, async tab => {
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
flex-shrink: 0; flex-shrink: 0;
} }
.ww-section-info-wrap {
display: inline-flex;
align-items: center;
flex-shrink: 0;
cursor: help;
}
.ww-section-info { .ww-section-info {
font-size: 13px; font-size: 13px;
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
flex-shrink: 0;
cursor: help;
} }
.ww-pie-wrap { .ww-pie-wrap {