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 { ElButton, ElPopover, ElTooltip } from 'element-plus';
import { $t } from '@/locales';
import IconMdiDotsHorizontal from '~icons/mdi/dots-horizontal';
import IconMdiChevronDown from '~icons/mdi/chevron-down';
export type BusinessTableAction = {
key: string;
@@ -162,11 +164,11 @@ export default defineComponent({
onClick={event => event.stopPropagation()}
>
{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">
{$t('common.more')}
<icon-mdi-chevron-down class="text-14px" />
<IconMdiChevronDown class="text-14px" />
</span>
)}
</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 => ({
...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`;
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & {
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId' | 'currentUserRoles'> & {
id: string | number;
managerUserId?: string | number | null;
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
};
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
@@ -39,7 +41,8 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
return {
...product,
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<
Api.Project.Project,
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
| 'id'
| 'managerUserId'
| 'productId'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
| 'currentUserRoles'
> & {
id: string | number;
managerUserId?: string | number | null;
@@ -56,6 +63,8 @@ export type ProjectResponse = Omit<
plannedEndDate?: ProjectLocalDateValue;
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
};
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
@@ -96,7 +105,8 @@ export function normalizeProject(project: ProjectResponse): Api.Project.Project
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
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({
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
});
}

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ declare module 'vue' {
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.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']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.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']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['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']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['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']
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
@@ -137,6 +142,7 @@ declare module 'vue' {
IconLocalLogo: typeof import('~icons/local/logo')['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']
IconMaterialSymbolsPackage2: typeof import('~icons/material-symbols/package2')['default']
'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default']
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']

View File

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

View File

@@ -1,16 +1,13 @@
<script setup lang="tsx">
<script setup lang="ts">
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, 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';
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
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 ProductSearch from './modules/product-search.vue';
@@ -23,14 +20,12 @@ interface StatusVisualMeta {
icon: Component;
}
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams {
return {
pageNo: 1,
pageSize: 20,
pageSize: -1,
keyword: '',
directionCode: 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[]) {
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不做本地名称映射 */
const STATUS_VISUALS: Record<string, StatusVisualMeta> = {
active: { tone: 'teal', icon: VideoPlay },
@@ -89,7 +58,11 @@ const operateVisible = ref(false);
const editingRow = ref<Api.Product.Product | null>(null);
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 升序) */
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]));
});
/** 当前筛选口径下可见产品跨方向数≥2 时渲染方向分层,否则单方向平铺 */
const directionCount = computed(() => new Set(allProducts.value.map(item => item.directionCode || '')).size);
const showDirectionLayer = computed(() => directionCount.value >= 2);
const statusItems = computed(() => [
{ key: 'all', label: '全部产品', count: statusBoardTotal.value, tone: 'sky' as StatusNavTone, icon: Menu },
...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 {
return {
...searchParams,
pageNo: 1,
pageSize: -1,
keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value === 'all' ? undefined : selectedStatus.value
};
}
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
ProductPageResponse,
Api.Product.Product
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetProductPage(createRequestParams()),
transform: response => transformProductPage(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: '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)
async function loadProducts() {
loading.value = true;
try {
const { error, data } = await fetchGetProductPage(createRequestParams());
allProducts.value = !error && data ? data.list : [];
} catch {
allProducts.value = [];
window.$message?.error('产品列表加载失败');
} finally {
loading.value = false;
}
],
immediate: false
});
}
async function loadManagerOptions() {
const { error, data: userList } = await fetchGetUserSimpleList();
@@ -223,31 +137,23 @@ async function loadOverviewData() {
statusBoardTotal.value = error || !overviewSummary ? 0 : overviewSummary.total;
}
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
async function refreshPageData(page = searchParams.pageNo ?? 1) {
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProductTable(page)]);
async function refreshPageData() {
await Promise.all([loadManagerOptions(), loadOverviewData(), loadProducts()]);
}
async function handleSearch() {
await reloadProductTable(1);
await loadProducts();
}
async function handleResetSearch() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, getInitSearchParams(), {
pageSize
});
await reloadProductTable(1);
Object.assign(searchParams, getInitSearchParams());
await loadProducts();
}
async function handleStatusChange(status: string) {
selectedStatus.value = status;
await Promise.all([loadOverviewData(), reloadProductTable(1)]);
allCollapsed.value = false;
await Promise.all([loadOverviewData(), loadProducts()]);
}
function openCreate() {
@@ -267,7 +173,7 @@ async function enterProductContext(row: Api.Product.Product) {
async function handleProductSubmitted(productId?: string) {
const isEditing = Boolean(productId && editingRow.value?.id === productId);
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
await refreshPageData();
if (isEditing) {
editingRow.value = null;
@@ -319,7 +225,7 @@ onMounted(async () => {
@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>
<div class="product-card-header">
<div class="min-w-0 flex-1">
@@ -331,43 +237,45 @@ onMounted(async () => {
getProductStatusLabel(selectedStatus)
}}
</ElTag>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
<ElTag effect="plain">{{ allProducts.length }}</ElTag>
</div>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="refreshPageData"
<div class="flex flex-none items-center gap-8px">
<ElButton
plain
:disabled="!showDirectionLayer || !allProducts.length"
@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">
<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>
</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']"
<ProductGroupedTable
:products="allProducts"
:loading="loading"
:show-direction-layer="showDirectionLayer"
:all-collapsed="allCollapsed"
:manager-label-map="managerLabelMap"
@enter="enterProductContext"
/>
</div>
</ElCard>
@@ -483,12 +391,6 @@ onMounted(async () => {
color: rgb(225 29 72 / 92%);
}
:deep(.product-table-card-body) {
display: flex;
flex: 1;
flex-direction: column;
}
.product-card-header {
display: flex;
align-items: center;
@@ -496,10 +398,6 @@ onMounted(async () => {
gap: 12px;
}
.product-name-link {
padding: 0;
}
@media (width <= 1280px) {
.product-card-header,
.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 { ElButton, ElEmpty, ElProgress, ElTag, ElTooltip } from 'element-plus';
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 { useDict } from '@/hooks/business/dict';
import CurrentUserRoleTags from '@/components/custom/current-user-role-tags.vue';
import { getProjectStatusLabel, getProjectStatusTagType } from '../../shared/project-master-data';
defineOptions({ name: 'ProjectGroupedTable' });
@@ -202,7 +203,8 @@ interface FlatRow {
project?: Api.Project.Project;
}
const COLUMN_COUNT = 7;
// 项目行列数(名称/类型/经理/我的角色/进度/计划周期/状态/更新),非项目行整行合并用
const COLUMN_COUNT = 8;
/** 单个产品组铺平为行:产品行 +(未折叠时)占位/项目/收纳行 */
function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
@@ -218,7 +220,12 @@ function buildGroupRows(group: Api.Project.ProjectGroup): FlatRow[] {
}
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) {
@@ -396,10 +403,8 @@ function overdueDays(project: Api.Project.Project) {
<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>
<ElIcon v-if="row.group.orphan"><QuestionFilled /></ElIcon>
<icon-material-symbols-package-2 v-else />
</span>
<span class="pg-prod-name" :class="{ 'pg-prod-name--orphan': row.group.orphan }">
{{ row.group.productName }}
@@ -429,11 +434,16 @@ function overdueDays(project: Api.Project.Project) {
</div>
<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)">
{{ row.project.projectName }}
</ElButton>
<div class="pg-sub-code">{{ row.project.projectCode }}</div>
</div>
</div>
<div v-else class="pg-more-line">
<template v-if="expandingKeys.has(groupKey(row.group))">
@@ -454,18 +464,24 @@ function overdueDays(project: Api.Project.Project) {
</template>
</ElTableColumn>
<ElTableColumn label="项目类型" width="110" align="left" show-overflow-tooltip>
<ElTableColumn label="项目类型" width="110" align="center" 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>
<ElTableColumn label="项目经理" width="100" align="center" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row.rowType === 'project'">{{ projectManagerLabel(row.project) }}</template>
</template>
</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">
<template #default="{ row }">
<div v-if="row.rowType === 'project'" class="pg-progress">
@@ -660,13 +676,44 @@ function overdueDays(project: Api.Project.Project) {
padding: 0;
}
// === 提示行 / 项目行 / 收纳行:统一缩进到产品名起始位 ===
// === 提示行 / 收纳行:统一缩进到产品名起始位 ===
.pg-hint,
.pg-more-line,
.pg-proj-name {
.pg-more-line {
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 {
display: flex;
align-items: center;

View File

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

View File

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

View File

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