feat(product): 新增产品管理模块与字典组件功能
- 新增产品管理相关路由和页面(dashboard、list、requirement、setting) - 实现产品基础信息编辑弹窗组件(base-info-dialog.vue) - 添加运行时字典功能(dict-select、dict-text、dict-tag组件) - 集成字典管理store和API调用 - 规范ID类型定义为string避免精度丢失问题 - 完善国际化资源文件支持中英文对照 - 新增对象上下文业务域入口页导航实现说明 - 添加Vue DevTools浮动入口注释说明 - 统一权限控制支持全局和对象作用域区分 - 规范分页查询参数类型定义与使用方式
This commit is contained in:
867
src/views/product/dashboard/index.vue
Normal file
867
src/views/product/dashboard/index.vue
Normal file
@@ -0,0 +1,867 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import { getProductLifecycleStatusSummary } from '../setting/shared';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
|
||||
import {
|
||||
getProductDashboardActivityItems,
|
||||
getProductDashboardGrowthModules,
|
||||
getProductDashboardMetricCards,
|
||||
getProductDashboardQuickLinks,
|
||||
getProductDashboardRdMilestonePlaceholder,
|
||||
getProductDashboardRecentActivityPlaceholder,
|
||||
getProductDashboardTeamSummary
|
||||
} from './shared';
|
||||
|
||||
defineOptions({ name: 'ProductDashboard' });
|
||||
|
||||
const { currentObjectId } = useCurrentProduct();
|
||||
const { routerPush, routerPushByKey } = useRouterPush();
|
||||
|
||||
const pageLoading = ref(false);
|
||||
const productDetail = ref<Api.Product.Product | null>(null);
|
||||
const settings = ref<Api.Product.ProductSettings | null>(null);
|
||||
const members = ref<Api.Product.ProductMember[]>([]);
|
||||
|
||||
const recentActivityPlaceholder = getProductDashboardRecentActivityPlaceholder();
|
||||
const rdMilestonePlaceholder = getProductDashboardRdMilestonePlaceholder();
|
||||
const growthModules = getProductDashboardGrowthModules();
|
||||
const quickLinks = getProductDashboardQuickLinks();
|
||||
const lifecycleTrackItems: Api.Product.ProductStatusCode[] = ['active', 'paused', 'archived', 'abandoned'];
|
||||
|
||||
const metricCards = computed(() => getProductDashboardMetricCards(settings.value, members.value));
|
||||
const statusMetricCard = computed(() => metricCards.value.find(item => item.key === 'status') || null);
|
||||
const secondaryMetricCards = computed(() => metricCards.value.filter(item => item.key !== 'status'));
|
||||
const teamSummary = computed(() => getProductDashboardTeamSummary(settings.value, members.value));
|
||||
const activityItems = computed(() =>
|
||||
getProductDashboardActivityItems(productDetail.value, settings.value, members.value)
|
||||
);
|
||||
const lifecycle = computed(() => settings.value?.lifecycle || null);
|
||||
const lifecycleSummary = computed(() =>
|
||||
lifecycle.value ? getProductLifecycleStatusSummary(lifecycle.value.statusCode) : null
|
||||
);
|
||||
const lifecycleReason = computed(
|
||||
() => lifecycle.value?.lastStatusReason || settings.value?.baseInfo.lastStatusReason || ''
|
||||
);
|
||||
|
||||
async function loadDashboardData(objectId: string) {
|
||||
pageLoading.value = true;
|
||||
|
||||
try {
|
||||
const [productResult, settingsResult, membersResult] = await Promise.all([
|
||||
fetchGetProduct(objectId),
|
||||
fetchGetProductSettings(objectId),
|
||||
fetchGetProductMembers(objectId)
|
||||
]);
|
||||
|
||||
productDetail.value = productResult.error ? null : productResult.data || null;
|
||||
settings.value = settingsResult.error ? null : settingsResult.data || null;
|
||||
members.value = membersResult.error ? [] : membersResult.data || [];
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goToQuickLink(target: 'requirement' | 'setting' | 'list') {
|
||||
if (target === 'list') {
|
||||
await routerPush({
|
||||
path: '/product/list'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === 'requirement') {
|
||||
await routerPushByKey('product_requirement', {
|
||||
query: {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: currentObjectId.value
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await routerPushByKey('product_setting', {
|
||||
query: {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: currentObjectId.value
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async objectId => {
|
||||
if (!objectId) {
|
||||
productDetail.value = null;
|
||||
settings.value = null;
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await loadDashboardData(objectId);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="product-dashboard-page">
|
||||
<section class="product-dashboard-page__metrics">
|
||||
<article
|
||||
v-if="statusMetricCard"
|
||||
class="dashboard-status-hero"
|
||||
:class="[lifecycle ? `dashboard-status-hero--${lifecycle.statusCode}` : 'dashboard-status-hero--default']"
|
||||
>
|
||||
<div class="dashboard-status-hero__head">
|
||||
<span class="dashboard-status-hero__label">{{ statusMetricCard.label }}</span>
|
||||
<span class="dashboard-status-hero__meta-chip">{{ lifecycle?.availableActions.length || 0 }} 项动作</span>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-status-hero__body">
|
||||
<strong class="dashboard-status-hero__value">{{ statusMetricCard.value }}</strong>
|
||||
<p class="dashboard-status-hero__reason">
|
||||
{{ lifecycleReason || '当前没有补充状态原因' }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="dashboard-metric-stack">
|
||||
<article v-for="card in secondaryMetricCards" :key="card.key" class="dashboard-metric-card">
|
||||
<span class="dashboard-metric-card__label">{{ card.label }}</span>
|
||||
<strong class="dashboard-metric-card__value">{{ card.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-dashboard-page__main">
|
||||
<div class="product-dashboard-page__primary">
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">{{ recentActivityPlaceholder.title }}</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">当前先展示产品主数据、状态与团队关系可确认的已知动态。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="activityItems.length" class="dashboard-activity-list">
|
||||
<div
|
||||
v-for="item in activityItems"
|
||||
:key="item.key"
|
||||
class="dashboard-activity-item"
|
||||
:class="[`dashboard-activity-item--${item.tone}`]"
|
||||
>
|
||||
<div class="dashboard-activity-item__meta">
|
||||
<span class="dashboard-activity-item__tag">{{ item.tag }}</span>
|
||||
<span class="dashboard-activity-item__time">{{ item.time }}</span>
|
||||
</div>
|
||||
<strong class="dashboard-activity-item__title">{{ item.title }}</strong>
|
||||
<p class="dashboard-activity-item__content">{{ item.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="dashboard-placeholder-panel">
|
||||
<p class="dashboard-placeholder-panel__description">{{ recentActivityPlaceholder.description }}</p>
|
||||
<div class="dashboard-placeholder-panel__items">
|
||||
<div
|
||||
v-for="item in recentActivityPlaceholder.items"
|
||||
:key="item"
|
||||
class="dashboard-placeholder-panel__item"
|
||||
>
|
||||
<span class="dashboard-placeholder-panel__dot" />
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">生命周期概览</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">
|
||||
{{ lifecycleSummary?.description || '当前未获取到生命周期信息。' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="lifecycle" class="dashboard-lifecycle">
|
||||
<div class="dashboard-lifecycle__summary">
|
||||
<div class="dashboard-lifecycle__summary-main">
|
||||
<ElTag :type="getProductStatusTagType(lifecycle.statusCode)" round effect="light">
|
||||
{{ getProductStatusLabel(lifecycle.statusCode) }}
|
||||
</ElTag>
|
||||
<strong class="dashboard-lifecycle__summary-title">
|
||||
{{ lifecycleSummary?.caption || '当前状态待确认' }}
|
||||
</strong>
|
||||
</div>
|
||||
<p class="dashboard-lifecycle__reason">最近状态原因:{{ lifecycleReason || '暂无记录' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-lifecycle__actions">
|
||||
<div
|
||||
v-for="action in lifecycle.availableActions"
|
||||
:key="action.actionCode"
|
||||
class="dashboard-lifecycle__action-card"
|
||||
>
|
||||
<strong class="dashboard-lifecycle__action-name">{{ action.actionName }}</strong>
|
||||
<span class="dashboard-lifecycle__action-hint">
|
||||
{{ action.needReason ? '提交时需填写原因' : '提交时原因可选' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ElEmpty
|
||||
v-if="!lifecycle.availableActions.length"
|
||||
description="当前状态下没有可执行动作"
|
||||
:image-size="68"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-lifecycle__track">
|
||||
<div
|
||||
v-for="status in lifecycleTrackItems"
|
||||
:key="status"
|
||||
class="dashboard-lifecycle__track-item"
|
||||
:class="[{ 'dashboard-lifecycle__track-item--active': lifecycle.statusCode === status }]"
|
||||
>
|
||||
{{ getProductStatusLabel(status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="未获取到产品生命周期信息" :image-size="76" />
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="product-dashboard-page__secondary">
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">团队摘要</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">当前先展示有效成员、负责人和角色分布摘要。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-team-card">
|
||||
<div class="dashboard-team-card__stat-grid">
|
||||
<div class="dashboard-team-card__stat">
|
||||
<span class="dashboard-team-card__stat-label">当前经理</span>
|
||||
<strong class="dashboard-team-card__stat-value">{{ teamSummary.managerDisplayName }}</strong>
|
||||
</div>
|
||||
<div class="dashboard-team-card__stat">
|
||||
<span class="dashboard-team-card__stat-label">有效成员</span>
|
||||
<strong class="dashboard-team-card__stat-value">{{ teamSummary.activeMemberCount }} 人</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-team-card__detail">
|
||||
<span class="dashboard-team-card__detail-label">最近加入</span>
|
||||
<strong class="dashboard-team-card__detail-value">{{ teamSummary.latestJoinedMemberLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-team-card__roles">
|
||||
<span class="dashboard-team-card__detail-label">角色分布</span>
|
||||
<div v-if="teamSummary.roleSummaries.length" class="dashboard-team-card__role-list">
|
||||
<span v-for="item in teamSummary.roleSummaries" :key="item" class="dashboard-team-card__role-chip">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="64" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">快捷入口</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">首页只做导流,不在这里承接重表单和重列表。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-link-list">
|
||||
<button
|
||||
v-for="link in quickLinks"
|
||||
:key="link.key"
|
||||
type="button"
|
||||
class="dashboard-link-list__item"
|
||||
@click="goToQuickLink(link.key)"
|
||||
>
|
||||
<strong class="dashboard-link-list__title">{{ link.label }}</strong>
|
||||
<span class="dashboard-link-list__desc">{{ link.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">{{ rdMilestonePlaceholder.title }}</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">对象档案补充位,后续接真实聚合数据后直接替换。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-placeholder-panel dashboard-placeholder-panel--compact">
|
||||
<p class="dashboard-placeholder-panel__description">
|
||||
{{ rdMilestonePlaceholder.description }}
|
||||
</p>
|
||||
<div class="dashboard-placeholder-panel__items">
|
||||
<div v-for="item in rdMilestonePlaceholder.items" :key="item" class="dashboard-placeholder-panel__item">
|
||||
<span class="dashboard-placeholder-panel__dot" />
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-dashboard-page__growth">
|
||||
<ElCard v-for="module in growthModules" :key="module.key" class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">{{ module.title }}</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">当前保留正式布局位,后续可直接接入真实统计。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-growth-card">
|
||||
<p class="dashboard-growth-card__description">{{ module.description }}</p>
|
||||
<div class="dashboard-growth-card__indicators">
|
||||
<div v-for="item in module.indicators" :key="item" class="dashboard-growth-card__indicator">
|
||||
<span class="dashboard-growth-card__indicator-label">{{ item }}</span>
|
||||
<strong class="dashboard-growth-card__indicator-value">--</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-dashboard-page__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 152px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero--default {
|
||||
background: linear-gradient(135deg, rgb(248 250 252 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--active {
|
||||
border-color: rgb(167 243 208 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(16 185 129 / 16%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(236 253 245 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--paused {
|
||||
border-color: rgb(253 230 138 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(245 158 11 / 16%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(255 251 235 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--archived {
|
||||
border-color: rgb(203 213 225 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(100 116 139 / 14%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(248 250 252 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--abandoned {
|
||||
border-color: rgb(254 205 211 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(244 63 94 / 14%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(255 241 242 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(255 255 255 / 78%);
|
||||
color: rgb(51 65 85 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__value {
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 38px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__reason {
|
||||
color: rgb(51 65 85 / 92%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-metric-stack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-metric-card {
|
||||
display: flex;
|
||||
min-height: 152px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 18px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.dashboard-metric-card__label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-metric-card__value {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dashboard-metric-card--manager .dashboard-metric-card__value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.product-dashboard-page__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-dashboard-page__primary,
|
||||
.product-dashboard-page__secondary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel--compact {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__description {
|
||||
color: rgb(71 85 105 / 95%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 94%);
|
||||
color: rgb(15 23 42 / 92%);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(14 165 233 / 86%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-left-width: 4px;
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--sky {
|
||||
border-left-color: rgb(14 165 233 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--emerald {
|
||||
border-left-color: rgb(5 150 105 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--amber {
|
||||
border-left-color: rgb(217 119 6 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--rose {
|
||||
border-left-color: rgb(225 29 72 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--slate {
|
||||
border-left-color: rgb(71 85 105 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(241 245 249 / 96%);
|
||||
color: rgb(51 65 85 / 94%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__time {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__content {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__reason {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__action-card {
|
||||
display: flex;
|
||||
min-height: 96px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 94%);
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__action-name {
|
||||
color: rgb(15 23 42 / 95%);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__action-hint {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__track {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__track-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(241 245 249 / 98%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__track-item--active {
|
||||
background-color: rgb(15 23 42 / 92%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-team-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat-label,
|
||||
.dashboard-team-card__detail-label {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat-value,
|
||||
.dashboard-team-card__detail-value {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-team-card__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__roles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__role-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__role-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(239 246 255 / 96%);
|
||||
color: rgb(30 64 175 / 92%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-link-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-link-list__item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 98%);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-link-list__item:hover {
|
||||
border-color: rgb(125 211 252 / 92%);
|
||||
box-shadow: 0 12px 24px rgb(148 163 184 / 12%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dashboard-link-list__title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dashboard-link-list__desc {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.product-dashboard-page__growth {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-growth-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__description {
|
||||
color: rgb(71 85 105 / 95%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicators {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicator {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicator-label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicator-value {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-dashboard-page__main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-dashboard-page__metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-metric-stack,
|
||||
.product-dashboard-page__growth {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.dashboard-metric-stack,
|
||||
.product-dashboard-page__growth,
|
||||
.dashboard-lifecycle__actions,
|
||||
.dashboard-growth-card__indicators,
|
||||
.dashboard-team-card__stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary-main {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
267
src/views/product/dashboard/shared.ts
Normal file
267
src/views/product/dashboard/shared.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getProductStatusLabel } from '../shared/product-master-data';
|
||||
|
||||
export interface ProductDashboardMetricCard {
|
||||
key: 'status' | 'team' | 'manager' | 'action';
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardTeamSummary {
|
||||
managerDisplayName: string;
|
||||
activeMemberCount: number;
|
||||
latestJoinedMemberLabel: string;
|
||||
roleSummaries: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardQuickLink {
|
||||
key: 'requirement' | 'setting' | 'list';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardActivityItem {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
time: string;
|
||||
tag: string;
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductDashboardPlaceholderPanel {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardGrowthModule {
|
||||
key: 'requirement-analysis' | 'project-progress' | 'rd-milestone';
|
||||
title: string;
|
||||
description: string;
|
||||
indicators: string[];
|
||||
}
|
||||
|
||||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||||
return members.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||
}
|
||||
|
||||
function formatActivityTime(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function getProductDashboardMetricCards(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const managerDisplayName =
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--';
|
||||
const actionCount = settings?.lifecycle.availableActions.length || 0;
|
||||
const statusLabel = settings ? getProductStatusLabel(settings.lifecycle.statusCode) : '--';
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'status',
|
||||
label: '当前状态',
|
||||
value: statusLabel
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: '团队成员',
|
||||
value: `${activeMembers.length} 人`
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
label: '当前经理',
|
||||
value: managerDisplayName
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '可执行动作',
|
||||
value: `${actionCount} 项`
|
||||
}
|
||||
] satisfies ProductDashboardMetricCard[];
|
||||
}
|
||||
|
||||
export function getProductDashboardTeamSummary(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
): ProductDashboardTeamSummary {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const latestJoinedMember = activeMembers
|
||||
.slice()
|
||||
.sort((left, right) => getTimeValue(right.joinedTime) - getTimeValue(left.joinedTime))[0];
|
||||
const latestJoinedDate = latestJoinedMember ? dayjs(latestJoinedMember.joinedTime) : null;
|
||||
|
||||
const roleCounter = new Map<string, number>();
|
||||
|
||||
activeMembers.forEach(member => {
|
||||
const roleName = member.roleName || '未命名角色';
|
||||
|
||||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||
});
|
||||
|
||||
const roleSummaries = Array.from(roleCounter.entries())
|
||||
.sort((left, right) => {
|
||||
const leftManagerWeight = left[0].includes('经理') ? 0 : 1;
|
||||
const rightManagerWeight = right[0].includes('经理') ? 0 : 1;
|
||||
|
||||
if (leftManagerWeight !== rightManagerWeight) {
|
||||
return leftManagerWeight - rightManagerWeight;
|
||||
}
|
||||
|
||||
return left[0].localeCompare(right[0], 'zh-CN');
|
||||
})
|
||||
.map(([roleName, count]) => `${roleName} ${count} 人`);
|
||||
|
||||
return {
|
||||
managerDisplayName:
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--',
|
||||
activeMemberCount: activeMembers.length,
|
||||
latestJoinedMemberLabel:
|
||||
latestJoinedMember && latestJoinedDate?.isValid()
|
||||
? `${latestJoinedMember.userNickname} · ${latestJoinedDate.format('YYYY-MM-DD')}`
|
||||
: '--',
|
||||
roleSummaries
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductDashboardQuickLinks() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement',
|
||||
label: '进入需求页',
|
||||
description: '查看当前产品下的需求承接位'
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
label: '查看设置',
|
||||
description: '进入产品基础信息、团队和生命周期管理'
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: '返回列表',
|
||||
description: '退出当前对象视角,回到产品入口页'
|
||||
}
|
||||
] satisfies ProductDashboardQuickLink[];
|
||||
}
|
||||
|
||||
export function getProductDashboardActivityItems(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const items: ProductDashboardActivityItem[] = [];
|
||||
|
||||
if (product?.createTime) {
|
||||
items.push({
|
||||
key: `product-create-${product.id}`,
|
||||
title: '创建产品',
|
||||
content: `产品 ${product.name || product.code} 已建立并纳入对象上下文。`,
|
||||
time: product.createTime,
|
||||
tag: '创建',
|
||||
tone: 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (settings && settings.baseInfo.lastStatusReason && product?.updateTime) {
|
||||
const statusCode = settings.lifecycle.statusCode;
|
||||
let tone: ProductDashboardActivityItem['tone'] = 'slate';
|
||||
|
||||
if (statusCode === 'active') {
|
||||
tone = 'emerald';
|
||||
} else if (statusCode === 'paused') {
|
||||
tone = 'amber';
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: `product-status-${product.id}-${product.updateTime}`,
|
||||
title: `状态调整为${getProductStatusLabel(settings.lifecycle.statusCode)}`,
|
||||
content: settings.baseInfo.lastStatusReason,
|
||||
time: product.updateTime,
|
||||
tag: '状态',
|
||||
tone
|
||||
});
|
||||
}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.joinedTime) {
|
||||
items.push({
|
||||
key: `member-join-${member.id}`,
|
||||
title: '成员加入',
|
||||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||||
time: member.joinedTime,
|
||||
tag: '团队',
|
||||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (member.status === 1 && member.leftTime) {
|
||||
items.push({
|
||||
key: `member-leave-${member.id}`,
|
||||
title: '成员退出',
|
||||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||||
time: member.leftTime,
|
||||
tag: '团队',
|
||||
tone: 'rose'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatActivityTime(item.time)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProductDashboardRecentActivityPlaceholder() {
|
||||
return {
|
||||
title: '最近动态',
|
||||
description: '当前先基于产品详情、生命周期与团队关系展示已知动态;后续接入审计日志后可继续扩充为完整时间线。',
|
||||
items: ['产品创建记录', '状态调整记录', '成员加入记录', '成员退出记录']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardRdMilestonePlaceholder() {
|
||||
return {
|
||||
title: '研发令 / 里程碑摘要',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在这里展示年度研发令、关键节点和版本里程碑。',
|
||||
items: ['当前年度研发令', '历史研发令', '关键节点计划']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardGrowthModules() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement-analysis',
|
||||
title: '需求分析',
|
||||
description: '暂未接入需求统计接口,后续将展示需求总量、状态分布与优先级分布。',
|
||||
indicators: ['需求总数', '待处理数量', '高优先级数量']
|
||||
},
|
||||
{
|
||||
key: 'project-progress',
|
||||
title: '项目推进',
|
||||
description: '当前未汇总项目推进数据,后续将展示关联项目、里程碑与风险摘要。',
|
||||
indicators: ['关联项目数', '进行中项目', '近期里程碑']
|
||||
},
|
||||
{
|
||||
key: 'rd-milestone',
|
||||
title: '研发令与里程碑',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在此展示研发令编号与关键节点信息。',
|
||||
indicators: ['当前年度研发令', '历史研发令', '关键节点']
|
||||
}
|
||||
] satisfies ProductDashboardGrowthModule[];
|
||||
}
|
||||
696
src/views/product/list/index.vue
Normal file
696
src/views/product/list/index.vue
Normal file
@@ -0,0 +1,696 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { getProductStatusLabel, getProductStatusTagType, isProductEditable } from '../shared/product-master-data';
|
||||
import ProductOperateDialog from './modules/product-operate-dialog.vue';
|
||||
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';
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
|
||||
|
||||
const PRODUCT_OPTION_PAGE_SIZE = 200;
|
||||
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
||||
|
||||
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
directionCode: undefined,
|
||||
managerUserId: undefined,
|
||||
statusCode: undefined,
|
||||
updateTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
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 formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
async function fetchProductTotal(params: Api.Product.ProductSearchParams) {
|
||||
const { error, data } = await fetchGetProductPage({
|
||||
...params,
|
||||
pageNo: 1,
|
||||
pageSize: 1
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return data.total;
|
||||
}
|
||||
|
||||
async function fetchAllProducts() {
|
||||
async function collect(pageNo: number, list: Api.Product.Product[]): Promise<Api.Product.Product[] | null> {
|
||||
const { error, data } = await fetchGetProductPage({
|
||||
pageNo,
|
||||
pageSize: PRODUCT_OPTION_PAGE_SIZE
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextList = list.concat(data.list);
|
||||
|
||||
if (nextList.length >= data.total || data.list.length === 0) {
|
||||
return nextList;
|
||||
}
|
||||
|
||||
return collect(pageNo + 1, nextList);
|
||||
}
|
||||
|
||||
return collect(1, []);
|
||||
}
|
||||
|
||||
function createManagerOptions(products: Api.Product.Product[], users: Api.SystemManage.UserSimple[]) {
|
||||
const managerIdSet = new Set(products.map(item => String(item.managerUserId)).filter(Boolean));
|
||||
const userMap = new Map(users.map(item => [String(item.id), item]));
|
||||
|
||||
const options = Array.from(managerIdSet).map(managerUserId => {
|
||||
return (
|
||||
userMap.get(managerUserId) || {
|
||||
id: managerUserId,
|
||||
nickname: String(managerUserId)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return sortManagerOptions(options);
|
||||
}
|
||||
|
||||
const statusNavMetas: StatusNavMeta[] = [
|
||||
{
|
||||
key: 'active',
|
||||
label: '启用产品',
|
||||
description: '当前正常服务中的产品',
|
||||
tone: 'teal',
|
||||
icon: CircleCheckFilled
|
||||
},
|
||||
{
|
||||
key: 'archived',
|
||||
label: '归档产品',
|
||||
description: '已完成阶段目标的产品',
|
||||
tone: 'slate',
|
||||
icon: FolderOpened
|
||||
},
|
||||
{
|
||||
key: 'paused',
|
||||
label: '暂停产品',
|
||||
description: '阶段性暂停投入的产品',
|
||||
tone: 'amber',
|
||||
icon: VideoPause
|
||||
},
|
||||
{
|
||||
key: 'abandoned',
|
||||
label: '废弃产品',
|
||||
description: '已明确停止建设的产品',
|
||||
tone: 'rose',
|
||||
icon: DeleteFilled
|
||||
}
|
||||
];
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedStatus = ref<Api.Product.ProductStatusCode>('active');
|
||||
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const operateVisible = ref(false);
|
||||
const editingRow = ref<Api.Product.Product | null>(null);
|
||||
const { routerPush } = useRouterPush();
|
||||
|
||||
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
|
||||
const statusCounts = ref<Record<Api.Product.ProductStatusCode, number>>({
|
||||
active: 0,
|
||||
archived: 0,
|
||||
paused: 0,
|
||||
abandoned: 0
|
||||
});
|
||||
|
||||
const recentUpdatedCount = 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]
|
||||
}))
|
||||
);
|
||||
|
||||
const overviewMetrics = computed(() => [
|
||||
{
|
||||
label: '可见产品',
|
||||
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
|
||||
hint: '当前接口可查询到的产品总量'
|
||||
},
|
||||
{
|
||||
label: '当前启用',
|
||||
value: statusCounts.value.active,
|
||||
hint: '正在持续服务和维护的产品'
|
||||
},
|
||||
{
|
||||
label: '产品方向',
|
||||
value: directionOptions.value.length,
|
||||
hint: '已加载的方向字典项数量'
|
||||
},
|
||||
{
|
||||
label: '30天内更新',
|
||||
value: recentUpdatedCount.value,
|
||||
hint: '最近 30 天内发生过更新的产品'
|
||||
}
|
||||
]);
|
||||
|
||||
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,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
statusCode: 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 => formatDateTime(row.updateTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 108,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<BusinessTableActionCell
|
||||
actions={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
buttonType: 'primary',
|
||||
disabled: !isProductEditable(row.statusCode),
|
||||
onClick: () => openEdit(row)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
async function loadManagerOptions() {
|
||||
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
|
||||
|
||||
const userSimpleList =
|
||||
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
|
||||
|
||||
managerUserOptions.value = userSimpleList;
|
||||
|
||||
if (!allProducts) {
|
||||
managerFilterOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
managerFilterOptions.value = createManagerOptions(allProducts, userSimpleList);
|
||||
}
|
||||
|
||||
async function loadOverviewData() {
|
||||
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
const start = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const [activeTotal, archivedTotal, pausedTotal, abandonedTotal, recentTotal] = await Promise.all([
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'active' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'archived' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'paused' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'abandoned' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, updateTime: [start, end] })
|
||||
]);
|
||||
|
||||
statusCounts.value = {
|
||||
active: activeTotal,
|
||||
archived: archivedTotal,
|
||||
paused: pausedTotal,
|
||||
abandoned: abandonedTotal
|
||||
};
|
||||
recentUpdatedCount.value = recentTotal;
|
||||
}
|
||||
|
||||
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 handleSearch() {
|
||||
await reloadProductTable(1);
|
||||
}
|
||||
|
||||
async function handleResetSearch() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize
|
||||
});
|
||||
|
||||
await reloadProductTable(1);
|
||||
}
|
||||
|
||||
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
|
||||
selectedStatus.value = status;
|
||||
await reloadProductTable(1);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingRow.value = null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.Product.Product) {
|
||||
editingRow.value = row;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
async function enterProductContext(row: Api.Product.Product) {
|
||||
await routerPush({
|
||||
path: PRODUCT_ENTRY_ROUTE_PATH,
|
||||
query: {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: row.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleProductSubmitted(productId?: string) {
|
||||
const isEditing = Boolean(productId && editingRow.value?.id === productId);
|
||||
|
||||
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
|
||||
|
||||
if (isEditing) {
|
||||
editingRow.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshPageData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<ElCard class="product-overview-card card-wrapper">
|
||||
<div class="product-overview-card__stats">
|
||||
<div v-for="item in overviewMetrics" :key="item.label" class="product-overview-card__stat">
|
||||
<span class="product-overview-card__stat-label">{{ item.label }}</span>
|
||||
<strong class="product-overview-card__stat-value">{{ item.value }}</strong>
|
||||
<small class="product-overview-card__stat-hint">{{ item.hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-status-panel__list">
|
||||
<button
|
||||
v-for="item in statusItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="product-status-item"
|
||||
:class="[`product-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
|
||||
:aria-pressed="selectedStatus === item.key"
|
||||
@click="handleStatusChange(item.key)"
|
||||
>
|
||||
<div class="product-status-item__icon">
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="product-status-item__main">
|
||||
<div class="product-status-item__top">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<em>{{ item.count }}</em>
|
||||
</div>
|
||||
<p class="product-status-item__desc">{{ item.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<ProductSearch
|
||||
v-model:model="searchParams"
|
||||
:manager-options="managerFilterOptions"
|
||||
@reset="handleResetSearch"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="product-table-card-body">
|
||||
<template #header>
|
||||
<div class="product-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">产品列表</p>
|
||||
<ElTag effect="plain" :type="getProductStatusTagType(selectedStatus)">
|
||||
{{
|
||||
statusItems.find(item => item.key === selectedStatus)?.label ||
|
||||
getProductStatusLabel(selectedStatus)
|
||||
}}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="refreshPageData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openCreate">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
|
||||
<template #empty>
|
||||
<ElEmpty description="当前筛选条件下暂无产品" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</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>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ProductOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:manager-user-options="managerUserOptions"
|
||||
:row-data="editingRow"
|
||||
@submitted="handleProductSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-overview-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 8%), transparent 36%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.product-overview-card__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-overview-card__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 84%);
|
||||
}
|
||||
|
||||
.product-overview-card__stat-label {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.product-overview-card__stat-value {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.product-overview-card__stat-hint {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.product-status-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.product-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;
|
||||
}
|
||||
|
||||
.product-status-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 60%);
|
||||
}
|
||||
|
||||
.product-status-item.is-active {
|
||||
border-color: rgb(15 118 110 / 40%);
|
||||
box-shadow: 0 10px 24px rgb(15 118 110 / 8%);
|
||||
}
|
||||
|
||||
.product-status-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.product-status-item__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-status-item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-status-item__top strong {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-status-item__top em {
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-status-item__desc {
|
||||
color: rgb(100 116 139 / 94%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-status-item--teal .product-status-item__icon {
|
||||
background-color: rgb(240 253 250 / 96%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
}
|
||||
|
||||
.product-status-item--slate .product-status-item__icon {
|
||||
background-color: rgb(241 245 249 / 96%);
|
||||
color: rgb(51 65 85 / 92%);
|
||||
}
|
||||
|
||||
.product-status-item--amber .product-status-item__icon {
|
||||
background-color: rgb(255 251 235 / 96%);
|
||||
color: rgb(217 119 6 / 92%);
|
||||
}
|
||||
|
||||
.product-status-item--rose .product-status-item__icon {
|
||||
background-color: rgb(255 241 242 / 96%);
|
||||
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;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-name-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-card-header,
|
||||
.product-status-item__top {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.product-overview-card__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
111
src/views/product/list/modules/product-detail-dialog.vue
Normal file
111
src/views/product/list/modules/product-detail-dialog.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetProduct } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
|
||||
|
||||
defineOptions({ name: 'ProductDetailDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Product.Product | null;
|
||||
managerOptions: Api.SystemManage.UserSimple[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const detailLoading = ref(false);
|
||||
const detailData = ref<Api.Product.Product | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
return detailData.value?.name ? `产品详情 - ${detailData.value.name}` : '产品详情';
|
||||
});
|
||||
|
||||
const managerLabelMap = computed(() => {
|
||||
return new Map(props.managerOptions.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
function getManagerLabel(managerUserId?: string | null) {
|
||||
if (!managerUserId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
async function initDetail() {
|
||||
detailData.value = props.rowData ? { ...props.rowData } : null;
|
||||
|
||||
if (!props.rowData) {
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProduct(props.rowData.id);
|
||||
|
||||
detailLoading.value = false;
|
||||
|
||||
if (!error) {
|
||||
detailData.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
initDetail();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="lg"
|
||||
:loading="detailLoading"
|
||||
:show-footer="false"
|
||||
:scrollbar="false"
|
||||
>
|
||||
<template v-if="detailData">
|
||||
<div class="mb-16px flex flex-wrap items-center gap-8px">
|
||||
<ElTag>{{ detailData.code }}</ElTag>
|
||||
<ElTag :type="getProductStatusTagType(detailData.statusCode)">
|
||||
{{ getProductStatusLabel(detailData.statusCode) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<ElDescriptions :column="2" border>
|
||||
<ElDescriptionsItem label="产品名称">{{ detailData.name }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品方向">
|
||||
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="detailData.directionCode" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品经理">{{ getManagerLabel(detailData.managerUserId) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="创建时间">{{ formatTime(detailData.createTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="更新时间">{{ formatTime(detailData.updateTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近状态原因">{{ detailData.lastStatusReason || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品描述" :span="2">
|
||||
<span class="whitespace-pre-wrap">{{ detailData.description || '--' }}</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-else description="未获取到产品详情" />
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
140
src/views/product/list/modules/product-entry-card.vue
Normal file
140
src/views/product/list/modules/product-entry-card.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type DemoProduct, getProductHealthType, getProductStatusType } from '@/constants/product-demo';
|
||||
|
||||
defineOptions({ name: 'ProductEntryCard' });
|
||||
|
||||
interface Props {
|
||||
product: DemoProduct;
|
||||
entering?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
entering: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'enter', product: DemoProduct): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const quickFacts = computed(() => [
|
||||
{ label: '版本', value: props.product.version },
|
||||
{ label: '目标发版', value: props.product.releaseTarget },
|
||||
{ label: '团队规模', value: `${props.product.teamCount} 人` }
|
||||
]);
|
||||
|
||||
function handleEnter() {
|
||||
emit('enter', props.product);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="product-entry-card h-full">
|
||||
<div class="mb-14px flex items-start justify-between gap-12px">
|
||||
<div class="min-w-0">
|
||||
<div class="mb-8px flex flex-wrap items-center gap-8px">
|
||||
<span class="product-entry-card__code">{{ product.code }}</span>
|
||||
<ElTag :type="getProductStatusType(product.status)" round>{{ product.status }}</ElTag>
|
||||
<ElTag :type="getProductHealthType(product.health)" effect="dark" round>{{ product.health }}</ElTag>
|
||||
</div>
|
||||
<h3 class="mb-6px text-18px text-[#0f172a] font-700">{{ product.name }}</h3>
|
||||
<p class="text-13px text-[#64748b]">负责人:{{ product.owner }} / 阶段:{{ product.stage }}</p>
|
||||
</div>
|
||||
<div class="product-entry-card__pulse"></div>
|
||||
</div>
|
||||
|
||||
<p class="mb-14px min-h-[66px] text-14px text-[#475569] leading-22px">{{ product.summary }}</p>
|
||||
|
||||
<div class="mb-14px flex flex-wrap gap-8px">
|
||||
<ElTag v-for="tag in product.tags" :key="tag" effect="plain" round>{{ tag }}</ElTag>
|
||||
</div>
|
||||
|
||||
<div class="grid mb-14px gap-10px sm:grid-cols-3">
|
||||
<div v-for="item in quickFacts" :key="item.label" class="product-entry-card__fact">
|
||||
<span class="product-entry-card__fact-label">{{ item.label }}</span>
|
||||
<strong class="product-entry-card__fact-value">{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-16px rounded-16px bg-[#f8fafc] p-12px">
|
||||
<p class="mb-8px text-12px text-[#94a3b8] tracking-[0.08em] uppercase">当前聚焦</p>
|
||||
<div class="flex flex-wrap gap-8px">
|
||||
<span v-for="item in product.focus" :key="item" class="product-entry-card__focus-chip">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="text-13px text-[#64748b]">
|
||||
<span>需求 {{ product.requirementCount }}</span>
|
||||
<span class="mx-8px text-[#cbd5e1]">|</span>
|
||||
<span>缺陷 {{ product.bugCount }}</span>
|
||||
</div>
|
||||
<ElButton type="primary" :loading="entering" @click="handleEnter">进入产品</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-entry-card {
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(16 185 129 / 7%), transparent 28%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
|
||||
}
|
||||
|
||||
.product-entry-card__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(15 23 42 / 92%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.product-entry-card__pulse {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 6px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgb(14 165 233 / 82%), rgb(14 165 233 / 16%));
|
||||
box-shadow: 0 0 0 6px rgb(14 165 233 / 8%);
|
||||
}
|
||||
|
||||
.product-entry-card__fact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background-color: rgb(241 245 249 / 88%);
|
||||
}
|
||||
|
||||
.product-entry-card__fact-label {
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-entry-card__fact-value {
|
||||
color: rgb(15 23 42 / 90%);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.product-entry-card__focus-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px dashed rgb(125 211 252 / 80%);
|
||||
border-radius: 999px;
|
||||
color: rgb(14 116 144 / 92%);
|
||||
font-size: 12px;
|
||||
background-color: rgb(236 254 255 / 88%);
|
||||
}
|
||||
</style>
|
||||
267
src/views/product/list/modules/product-operate-dialog.vue
Normal file
267
src/views/product/list/modules/product-operate-dialog.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
|
||||
defineOptions({ name: 'ProductOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
managerUserOptions: Api.SystemManage.UserSimple[];
|
||||
rowData?: Api.Product.Product | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', productId?: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
interface Model {
|
||||
code: string;
|
||||
directionCode: string;
|
||||
name: string;
|
||||
managerUserId: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const isEditMode = computed(() => Boolean(props.rowData?.id));
|
||||
const dialogTitle = computed(() => (isEditMode.value ? '编辑产品' : '新增产品'));
|
||||
const submitting = ref(false);
|
||||
const loading = ref(false);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
|
||||
const managerDisplayName = computed(() => {
|
||||
const managerUserId = model.value.managerUserId;
|
||||
|
||||
if (!managerUserId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||
});
|
||||
|
||||
const rules = {
|
||||
directionCode: [createRequiredRule('请选择产品方向')],
|
||||
name: [createRequiredRule('请输入产品名称')],
|
||||
managerUserId: [createRequiredRule('请选择产品经理')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
code: '',
|
||||
directionCode: '',
|
||||
name: '',
|
||||
managerUserId: null,
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
const managerUserId = model.value.managerUserId;
|
||||
|
||||
if (!managerUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Api.Product.SaveProductParams = {
|
||||
code: getNullableText(model.value.code),
|
||||
directionCode: model.value.directionCode,
|
||||
name: model.value.name.trim(),
|
||||
// Long ID 必须以 string 提交,禁止再转成 number。
|
||||
managerUserId,
|
||||
description: getNullableText(model.value.description)
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
if (isEditMode.value && props.rowData?.id) {
|
||||
const result = await fetchUpdateProduct({
|
||||
id: props.rowData.id,
|
||||
...payload
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('产品编辑成功');
|
||||
closeDialog();
|
||||
emit('submitted', props.rowData.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchCreateProduct(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('产品新增成功');
|
||||
closeDialog();
|
||||
emit('submitted', result.data);
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditMode.value || !props.rowData?.id) {
|
||||
model.value = createDefaultModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProduct(props.rowData.id);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = {
|
||||
code: data.code || '',
|
||||
directionCode: data.directionCode || '',
|
||||
name: data.name || '',
|
||||
managerUserId: data.managerUserId || null,
|
||||
description: data.description || ''
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="lg"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
|
||||
<ElInput
|
||||
:model-value="model.code"
|
||||
readonly
|
||||
class="product-operate-dialog__readonly-input"
|
||||
placeholder="未获取到产品编码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else label="产品编码" prop="code">
|
||||
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品名称" prop="name">
|
||||
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品方向" prop="directionCode">
|
||||
<DictSelect
|
||||
v-model="model.directionCode"
|
||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||
filterable
|
||||
placeholder="请选择产品方向"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem v-if="isEditMode">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<ElTooltip
|
||||
content="如需调整产品经理,请到产品内的团队管理处处理。"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
<span class="business-form-label-tip">
|
||||
<icon-fe:question />
|
||||
</span>
|
||||
</ElTooltip>
|
||||
<span>产品经理</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElInput
|
||||
:model-value="managerDisplayName"
|
||||
readonly
|
||||
class="product-operate-dialog__readonly-input"
|
||||
placeholder="未配置产品经理"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else label="产品经理" prop="managerUserId">
|
||||
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="请选择产品经理">
|
||||
<ElOption v-for="item in managerUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="产品描述" prop="description">
|
||||
<ElInput
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.product-operate-dialog__readonly-input .el-input__wrapper) {
|
||||
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:deep(.product-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||
:deep(.product-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
}
|
||||
|
||||
:deep(.product-operate-dialog__readonly-input .el-input__inner) {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: default;
|
||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
</style>
|
||||
59
src/views/product/list/modules/product-search.vue
Normal file
59
src/views/product/list/modules/product-search.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
|
||||
defineOptions({ name: 'ProductSearch' });
|
||||
|
||||
interface Props {
|
||||
managerOptions: Api.SystemManage.UserSimple[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
|
||||
|
||||
function reset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="关键词">
|
||||
<ElInput v-model="model.keyword" clearable placeholder="产品名称 / 编号" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="产品经理">
|
||||
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="筛选产品经理">
|
||||
<ElOption v-for="item in managerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="产品方向">
|
||||
<DictSelect
|
||||
v-model="model.directionCode"
|
||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||
filterable
|
||||
placeholder="筛选产品方向"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/product/requirement/index.vue
Normal file
7
src/views/product/requirement/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ProductRequirement' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>待开发</h1>
|
||||
</template>
|
||||
505
src/views/product/setting/index.vue
Normal file
505
src/views/product/setting/index.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import {
|
||||
fetchChangeProductStatus,
|
||||
fetchCreateProductMember,
|
||||
fetchDeleteProduct,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProductSettings,
|
||||
fetchGetRoleSimpleList,
|
||||
fetchGetUserSimpleList,
|
||||
fetchInactiveProductMember,
|
||||
fetchUpdateProductMember,
|
||||
fetchUpdateProductSettingBaseInfo
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import BaseInfoDialog from './modules/base-info-dialog.vue';
|
||||
import MemberOperateDialog from './modules/member-operate-dialog.vue';
|
||||
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
|
||||
import ProductDeleteDialog from './modules/product-delete-dialog.vue';
|
||||
import SettingAnchorNav from './modules/setting-anchor-nav.vue';
|
||||
import SettingBaseInfoCard from './modules/setting-base-info-card.vue';
|
||||
import SettingDangerZone from './modules/setting-danger-zone.vue';
|
||||
import SettingLifecyclePanel from './modules/setting-lifecycle-panel.vue';
|
||||
import SettingTeamPanel from './modules/setting-team-panel.vue';
|
||||
import StatusActionDialog from './modules/status-action-dialog.vue';
|
||||
import {
|
||||
type ProductSettingSectionKey,
|
||||
canManageProductTeam,
|
||||
getProductSettingSectionKeys,
|
||||
resolveVisibleProductSettingSectionKey,
|
||||
resolveVisibleProductSettingSections
|
||||
} from './shared';
|
||||
|
||||
defineOptions({ name: 'ProductSetting' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPush } = useRouterPush();
|
||||
const { currentObjectId, currentProduct } = useCurrentProduct();
|
||||
const isCompactLayout = useMediaQuery('(max-width: 1280px)');
|
||||
|
||||
const productDomainConfig = objectContextDomainConfigs.find(config => config.domainKey === 'product') || null;
|
||||
|
||||
const allAnchorItems = [
|
||||
{ key: 'base-info', label: '基础信息' },
|
||||
{ key: 'team', label: '团队管理' },
|
||||
{ key: 'lifecycle', label: '生命周期管理' },
|
||||
{ key: 'danger', label: '危险操作' }
|
||||
] as const;
|
||||
|
||||
const anchorLabelMap = new Map(allAnchorItems.map(item => [item.key, item.label]));
|
||||
|
||||
const sectionIdMap: Record<ProductSettingSectionKey, string> = {
|
||||
'base-info': 'product-setting-base-info',
|
||||
team: 'product-setting-team',
|
||||
lifecycle: 'product-setting-lifecycle',
|
||||
danger: 'product-setting-danger'
|
||||
};
|
||||
|
||||
const activeAnchorKey = ref<ProductSettingSectionKey>('base-info');
|
||||
const pageLoading = ref(false);
|
||||
const memberLoading = ref(false);
|
||||
const baseInfoVisible = ref(false);
|
||||
const memberOperateVisible = ref(false);
|
||||
const memberRemoveVisible = ref(false);
|
||||
const statusActionVisible = ref(false);
|
||||
const deleteVisible = ref(false);
|
||||
const memberOperateMode = ref<'create' | 'edit'>('create');
|
||||
const selectedMember = ref<Api.Product.ProductMember | null>(null);
|
||||
const selectedAction = ref<Api.Product.ProductLifecycleAction | null>(null);
|
||||
|
||||
const settings = ref<Api.Product.ProductSettings | null>(null);
|
||||
const members = ref<Api.Product.ProductMember[]>([]);
|
||||
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||
const userOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
|
||||
const currentManager = computed(() => members.value.find(item => item.managerFlag && item.status === 0) || null);
|
||||
const baseInfo = computed(() => settings.value?.baseInfo || null);
|
||||
const lifecycle = computed(() => settings.value?.lifecycle || null);
|
||||
const canManageTeam = computed(() =>
|
||||
canManageProductTeam({
|
||||
buttonCodes: objectContextStore.buttonCodes,
|
||||
loginUserId: authStore.userInfo.userId,
|
||||
currentManagerUserId: currentManager.value?.userId
|
||||
})
|
||||
);
|
||||
const visibleSectionKeys = computed(() =>
|
||||
resolveVisibleProductSettingSections(getProductSettingSectionKeys(), objectContextStore.buttonCodes)
|
||||
);
|
||||
const anchorItems = computed(() =>
|
||||
visibleSectionKeys.value.map(key => ({
|
||||
key,
|
||||
label: anchorLabelMap.get(key) || key
|
||||
}))
|
||||
);
|
||||
const layoutScrollTarget = `#${LAYOUT_SCROLL_EL_ID}`;
|
||||
const anchorAffixOffset = computed(() => {
|
||||
const fixedTopInset = themeStore.fixedHeaderAndTab
|
||||
? themeStore.header.height + (themeStore.tabVisible ? themeStore.tab.height : 0)
|
||||
: 0;
|
||||
|
||||
return fixedTopInset + 16;
|
||||
});
|
||||
const anchorShellInlineStyle = computed(() => ({
|
||||
maxHeight: isCompactLayout.value ? '' : `calc(100vh - ${anchorAffixOffset.value + 16}px)`
|
||||
}));
|
||||
const showLifecycleSection = computed(() => visibleSectionKeys.value.includes('lifecycle'));
|
||||
const showDangerSection = computed(() => visibleSectionKeys.value.includes('danger'));
|
||||
|
||||
async function loadSettings() {
|
||||
if (!currentObjectId.value) {
|
||||
settings.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProductSettings(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
settings.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
settings.value = data;
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
if (!currentObjectId.value) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
memberLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProductMembers(currentObjectId.value);
|
||||
|
||||
memberLoading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
members.value = data;
|
||||
}
|
||||
|
||||
async function loadRoleOptions() {
|
||||
const { error, data } = await fetchGetRoleSimpleList({
|
||||
scopeType: 'object',
|
||||
objectType: 'product'
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
roleOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
roleOptions.value = data;
|
||||
}
|
||||
|
||||
async function loadUserOptions() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
|
||||
if (error || !data) {
|
||||
userOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
userOptions.value = data;
|
||||
}
|
||||
|
||||
async function refreshContextSummary() {
|
||||
if (!productDomainConfig || !currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await objectContextStore.enterContext(productDomainConfig, currentObjectId.value);
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageLoading.value = true;
|
||||
|
||||
await Promise.all([loadSettings(), loadMembers(), loadRoleOptions(), loadUserOptions()]);
|
||||
|
||||
pageLoading.value = false;
|
||||
}
|
||||
|
||||
function scrollToSection(key: string) {
|
||||
if (!(key in sectionIdMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedKey = key as ProductSettingSectionKey;
|
||||
|
||||
if (!visibleSectionKeys.value.includes(resolvedKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeAnchorKey.value = resolvedKey;
|
||||
const target = document.getElementById(sectionIdMap[resolvedKey]);
|
||||
|
||||
target?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateMember() {
|
||||
memberOperateMode.value = 'create';
|
||||
selectedMember.value = null;
|
||||
memberOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditMember(member: Api.Product.ProductMember) {
|
||||
memberOperateMode.value = 'edit';
|
||||
selectedMember.value = member;
|
||||
memberOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openRemoveMember(member: Api.Product.ProductMember) {
|
||||
selectedMember.value = member;
|
||||
memberRemoveVisible.value = true;
|
||||
}
|
||||
|
||||
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
|
||||
selectedAction.value = action;
|
||||
statusActionVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmitBaseInfo(payload: Api.Product.UpdateProductSettingBaseInfoParams) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchUpdateProductSettingBaseInfo(currentObjectId.value, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('基础信息更新成功');
|
||||
baseInfoVisible.value = false;
|
||||
|
||||
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||
}
|
||||
|
||||
async function handleSubmitMemberOperate(event: {
|
||||
mode: 'create' | 'edit';
|
||||
memberId?: string;
|
||||
managerChanged: boolean;
|
||||
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
|
||||
}) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result =
|
||||
event.mode === 'create'
|
||||
? await fetchCreateProductMember(currentObjectId.value, event.payload as Api.Product.CreateProductMemberParams)
|
||||
: await fetchUpdateProductMember(
|
||||
currentObjectId.value,
|
||||
event.memberId || '',
|
||||
event.payload as Api.Product.UpdateProductMemberParams
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(event.mode === 'create' ? '成员新增成功' : '成员角色调整成功');
|
||||
memberOperateVisible.value = false;
|
||||
|
||||
await Promise.all([loadMembers(), loadSettings()]);
|
||||
|
||||
if (event.managerChanged) {
|
||||
await refreshContextSummary();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
|
||||
if (!currentObjectId.value || !selectedMember.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchInactiveProductMember(currentObjectId.value, selectedMember.value.id, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('成员移出成功');
|
||||
memberRemoveVisible.value = false;
|
||||
|
||||
await Promise.all([loadMembers(), loadSettings()]);
|
||||
}
|
||||
|
||||
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
|
||||
if (!currentObjectId.value || !selectedAction.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchChangeProductStatus({
|
||||
...payload,
|
||||
id: currentObjectId.value
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(`${selectedAction.value.actionName}成功`);
|
||||
statusActionVisible.value = false;
|
||||
|
||||
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||
}
|
||||
|
||||
async function handleSubmitDelete(payload: Api.Product.DeleteProductParams) {
|
||||
const result = await fetchDeleteProduct(payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('产品删除成功');
|
||||
deleteVisible.value = false;
|
||||
objectContextStore.clearContext();
|
||||
|
||||
await routerPush({
|
||||
path: '/product/list'
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
visibleSectionKeys,
|
||||
sectionKeys => {
|
||||
activeAnchorKey.value = resolveVisibleProductSettingSectionKey(activeAnchorKey.value, sectionKeys);
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async objectId => {
|
||||
if (!objectId) {
|
||||
settings.value = null;
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPageData();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="product-setting-page">
|
||||
<div class="product-setting-page__body">
|
||||
<div class="product-setting-page__aside">
|
||||
<div v-if="isCompactLayout" class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||
</div>
|
||||
<ElAffix
|
||||
v-else
|
||||
class="product-setting-page__aside-affix"
|
||||
:offset="anchorAffixOffset"
|
||||
:target="layoutScrollTarget"
|
||||
teleported
|
||||
>
|
||||
<div class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||
</div>
|
||||
</ElAffix>
|
||||
</div>
|
||||
|
||||
<div class="product-setting-page__content">
|
||||
<section :id="sectionIdMap['base-info']" class="product-setting-page__section">
|
||||
<SettingBaseInfoCard :base-info="baseInfo" @edit="baseInfoVisible = true" />
|
||||
</section>
|
||||
|
||||
<section :id="sectionIdMap.team" class="product-setting-page__section">
|
||||
<SettingTeamPanel
|
||||
:members="members"
|
||||
:role-options="roleOptions"
|
||||
:loading="memberLoading"
|
||||
:readonly="!canManageTeam"
|
||||
@create="openCreateMember"
|
||||
@edit="openEditMember"
|
||||
@remove="openRemoveMember"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="showLifecycleSection" :id="sectionIdMap.lifecycle" class="product-setting-page__section">
|
||||
<SettingLifecyclePanel :lifecycle="lifecycle" @action="openLifecycleAction" />
|
||||
</section>
|
||||
|
||||
<section v-if="showDangerSection" :id="sectionIdMap.danger" class="product-setting-page__section">
|
||||
<SettingDangerZone
|
||||
:product-name="baseInfo?.name || currentProduct?.name || ''"
|
||||
@delete="deleteVisible = true"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseInfoDialog v-model:visible="baseInfoVisible" :base-info="baseInfo" @submit="handleSubmitBaseInfo" />
|
||||
<MemberOperateDialog
|
||||
v-model:visible="memberOperateVisible"
|
||||
:mode="memberOperateMode"
|
||||
:member="selectedMember"
|
||||
:current-manager="currentManager"
|
||||
:role-options="roleOptions"
|
||||
:user-options="userOptions"
|
||||
@submit="handleSubmitMemberOperate"
|
||||
/>
|
||||
<MemberRemoveDialog
|
||||
v-model:visible="memberRemoveVisible"
|
||||
:member="selectedMember"
|
||||
@submit="handleSubmitRemoveMember"
|
||||
/>
|
||||
<StatusActionDialog
|
||||
v-model:visible="statusActionVisible"
|
||||
:action="selectedAction"
|
||||
@submit="handleSubmitLifecycleAction"
|
||||
/>
|
||||
<ProductDeleteDialog
|
||||
v-model:visible="deleteVisible"
|
||||
:product-id="currentObjectId"
|
||||
:product-name="baseInfo?.name || currentProduct?.name || ''"
|
||||
@submit="handleSubmitDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-setting-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-setting-page__body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-setting-page__aside {
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-affix {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-shell {
|
||||
min-height: 100%;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 7%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.product-setting-page__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-setting-page__section {
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-setting-page__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-shell {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
src/views/product/setting/modules/base-info-dialog.vue
Normal file
218
src/views/product/setting/modules/base-info-dialog.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import { getProductBaseInfoReadonlyMessage, isProductBaseInfoEditable } from '../shared';
|
||||
|
||||
defineOptions({ name: 'BaseInfoDialog' });
|
||||
|
||||
interface Props {
|
||||
baseInfo: Api.Product.ProductSettingBaseInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.UpdateProductSettingBaseInfoParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const model = reactive<Api.Product.UpdateProductSettingBaseInfoParams>({
|
||||
directionCode: '',
|
||||
name: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
const baseInfoEditable = computed(() => isProductBaseInfoEditable(props.baseInfo?.statusCode));
|
||||
const readonlyMessage = computed(() => getProductBaseInfoReadonlyMessage(props.baseInfo?.statusCode));
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
if (!props.baseInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !baseInfoEditable.value;
|
||||
});
|
||||
|
||||
const directionDisplayName = computed(() => {
|
||||
const directionCode = props.baseInfo?.directionCode;
|
||||
|
||||
if (!directionCode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getDirectionLabel(directionCode, directionCode);
|
||||
});
|
||||
|
||||
const rules = {
|
||||
directionCode: [createRequiredRule('请选择产品方向')],
|
||||
name: [createRequiredRule('请输入产品名称')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
async function handleConfirm() {
|
||||
if (confirmDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
emit('submit', {
|
||||
directionCode: model.directionCode,
|
||||
name: model.name.trim(),
|
||||
description: model.description?.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value || !props.baseInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.directionCode = props.baseInfo.directionCode || '';
|
||||
model.name = props.baseInfo.name || '';
|
||||
model.description = props.baseInfo.description || '';
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="编辑基础信息"
|
||||
preset="lg"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert v-if="readonlyMessage" :title="readonlyMessage" type="warning" :closable="false" class="mb-16px" />
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品编码">
|
||||
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem>
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<ElTooltip
|
||||
content="如需调整产品经理,请到产品内的团队管理处处理。"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
<span class="business-form-label-tip">
|
||||
<icon-fe:question />
|
||||
</span>
|
||||
</ElTooltip>
|
||||
<span>产品经理</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElInput
|
||||
:model-value="baseInfo?.managerUserNickname || baseInfo?.managerUserId || ''"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品名称" prop="name">
|
||||
<ElInput v-if="baseInfoEditable" v-model="model.name" maxlength="128" placeholder="请输入产品名称" />
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="model.name"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未获取到产品名称"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品方向" prop="directionCode">
|
||||
<DictSelect
|
||||
v-if="baseInfoEditable"
|
||||
v-model="model.directionCode"
|
||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||
filterable
|
||||
placeholder="请选择产品方向"
|
||||
/>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="directionDisplayName"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未获取到产品方向"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="产品描述">
|
||||
<ElInput
|
||||
v-if="baseInfoEditable"
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未填写产品描述"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.base-info-dialog__readonly-input .el-input__wrapper) {
|
||||
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
cursor: default;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-input__wrapper:hover),
|
||||
:deep(.base-info-dialog__readonly-input.is-focus .el-input__wrapper),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner:hover),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner:focus) {
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-input__inner),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: default;
|
||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
</style>
|
||||
203
src/views/product/setting/modules/member-operate-dialog.vue
Normal file
203
src/views/product/setting/modules/member-operate-dialog.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
|
||||
|
||||
defineOptions({ name: 'MemberOperateDialog' });
|
||||
|
||||
type OperateMode = 'create' | 'edit';
|
||||
|
||||
interface Props {
|
||||
mode: OperateMode;
|
||||
member: Api.Product.ProductMember | null;
|
||||
currentManager: Api.Product.ProductMember | null;
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
userOptions: Api.SystemManage.UserSimple[];
|
||||
}
|
||||
|
||||
interface SubmitPayload {
|
||||
mode: OperateMode;
|
||||
memberId?: string;
|
||||
managerChanged: boolean;
|
||||
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: SubmitPayload): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
interface Model {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
remark: string;
|
||||
reason: string;
|
||||
previousManagerRoleId: string;
|
||||
}
|
||||
|
||||
const model = reactive<Model>({
|
||||
userId: '',
|
||||
roleId: '',
|
||||
remark: '',
|
||||
reason: '',
|
||||
previousManagerRoleId: ''
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
|
||||
const selectedUserId = computed(() => (props.mode === 'create' ? model.userId : props.member?.userId || ''));
|
||||
const showManagerHandover = computed(() => {
|
||||
return (
|
||||
shouldRequireManagerHandover(model.roleId, props.currentManager) &&
|
||||
Boolean(selectedUserId.value) &&
|
||||
selectedUserId.value !== props.currentManager?.userId
|
||||
);
|
||||
});
|
||||
const previousManagerRoleOptions = computed(() =>
|
||||
getPreviousManagerRoleOptions(props.roleOptions, props.currentManager?.roleId || '')
|
||||
);
|
||||
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [item.id, item.nickname])));
|
||||
|
||||
const rules = computed(
|
||||
() =>
|
||||
({
|
||||
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
|
||||
roleId: [createRequiredRule('请选择角色')],
|
||||
previousManagerRoleId: showManagerHandover.value ? [createRequiredRule('请选择原产品经理交接后角色')] : []
|
||||
}) satisfies Record<string, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
async function handleConfirm() {
|
||||
await validate();
|
||||
|
||||
const sharedPayload = {
|
||||
roleId: model.roleId,
|
||||
remark: model.remark.trim() || null,
|
||||
previousManagerUserId: showManagerHandover.value ? props.currentManager?.userId || null : null,
|
||||
previousManagerRoleId: showManagerHandover.value ? model.previousManagerRoleId : null
|
||||
};
|
||||
|
||||
if (props.mode === 'create') {
|
||||
emit('submit', {
|
||||
mode: 'create',
|
||||
managerChanged: showManagerHandover.value,
|
||||
payload: {
|
||||
userId: model.userId,
|
||||
roleId: sharedPayload.roleId,
|
||||
remark: sharedPayload.remark,
|
||||
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
mode: 'edit',
|
||||
memberId: props.member?.id,
|
||||
managerChanged: showManagerHandover.value,
|
||||
payload: {
|
||||
roleId: sharedPayload.roleId,
|
||||
remark: sharedPayload.remark,
|
||||
reason: model.reason.trim() || null,
|
||||
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.userId = props.mode === 'create' ? '' : props.member?.userId || '';
|
||||
model.roleId = props.mode === 'create' ? '' : props.member?.roleId || '';
|
||||
model.remark = props.mode === 'create' ? '' : props.member?.remark || '';
|
||||
model.reason = '';
|
||||
model.previousManagerRoleId = '';
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" @confirm="handleConfirm">
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<BusinessFormSection title="成员信息">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
||||
<ElSelect v-model="model.userId" class="w-full" filterable placeholder="请选择成员用户">
|
||||
<ElOption v-for="item in userOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else label="成员用户">
|
||||
<ElInput :model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''" readonly />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="目标角色" prop="roleId">
|
||||
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="备注">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="mode === 'edit'" title="角色调整说明">
|
||||
<ElFormItem label="变更原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入变更原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="showManagerHandover" title="产品经理交接">
|
||||
<ElAlert
|
||||
:title="`当前产品经理 ${currentManager?.userNickname || currentManager?.userId || ''} 将完成交接,请选择其交接后角色。`"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElFormItem label="原产品经理交接后角色" prop="previousManagerRoleId">
|
||||
<ElSelect v-model="model.previousManagerRoleId" class="w-full" placeholder="请选择原产品经理交接后角色">
|
||||
<ElOption v-for="item in previousManagerRoleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
65
src/views/product/setting/modules/member-remove-dialog.vue
Normal file
65
src/views/product/setting/modules/member-remove-dialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'MemberRemoveDialog' });
|
||||
|
||||
interface Props {
|
||||
member: Api.Product.ProductMember | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.InactiveProductMemberParams): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
emit('submit', {
|
||||
reason: model.reason.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" title="移出成员" preset="sm" @confirm="handleConfirm">
|
||||
<ElAlert
|
||||
:title="`确认将 ${member?.userNickname || member?.userId || '--'} 从当前产品团队中移出吗?`"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="移出原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入移出原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
84
src/views/product/setting/modules/product-delete-dialog.vue
Normal file
84
src/views/product/setting/modules/product-delete-dialog.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'ProductDeleteDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.DeleteProductParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
confirmName: '',
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
return !model.reason.trim() || model.confirmName.trim() !== props.productName;
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
emit('submit', {
|
||||
id: props.productId,
|
||||
productName: model.confirmName.trim(),
|
||||
reason: model.reason.trim()
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.confirmName = '';
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="删除产品"
|
||||
preset="sm"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
confirm-text="确认删除"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert
|
||||
:title="`请输入当前产品名称 ${productName || '--'} 完成二次确认,删除后将退出当前对象上下文。`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="删除确认名称">
|
||||
<ElInput v-model="model.confirmName" placeholder="请输入当前产品名称" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="删除原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入删除原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
90
src/views/product/setting/modules/setting-anchor-nav.vue
Normal file
90
src/views/product/setting/modules/setting-anchor-nav.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SettingAnchorNav' });
|
||||
|
||||
interface SettingAnchorItem {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: readonly SettingAnchorItem[];
|
||||
activeKey: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', key: string): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setting-anchor-nav">
|
||||
<div class="setting-anchor-nav__title">设置目录</div>
|
||||
<div class="setting-anchor-nav__list">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="setting-anchor-nav__item"
|
||||
:class="{ 'is-active': item.key === activeKey }"
|
||||
@click="emit('select', item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-anchor-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__title {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 56%);
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item.is-active {
|
||||
border-color: rgb(13 148 136 / 42%);
|
||||
background-color: rgb(240 253 250 / 98%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
78
src/views/product/setting/modules/setting-base-info-card.vue
Normal file
78
src/views/product/setting/modules/setting-base-info-card.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
|
||||
import { isProductBaseInfoEditable } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingBaseInfoCard' });
|
||||
|
||||
interface Props {
|
||||
baseInfo: Api.Product.ProductSettingBaseInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const editDisabled = computed(() => {
|
||||
if (!props.baseInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isProductBaseInfoEditable(props.baseInfo.statusCode);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">基础信息</h3>
|
||||
</div>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="editDisabled"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
编辑基础信息
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElDescriptions v-if="baseInfo" :column="2" border>
|
||||
<ElDescriptionsItem label="产品编码">{{ baseInfo.code || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品名称">{{ baseInfo.name || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品方向">
|
||||
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="baseInfo.directionCode" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品经理">
|
||||
{{ baseInfo.managerUserNickname || baseInfo.managerUserId || '--' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="当前状态">
|
||||
<ElTag :type="getProductStatusTagType(baseInfo.statusCode)">
|
||||
{{ getProductStatusLabel(baseInfo.statusCode) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近状态原因">{{ baseInfo.lastStatusReason || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品描述" :span="2">
|
||||
<div class="setting-base-info-card__description">{{ baseInfo.description || '--' }}</div>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElEmpty v-else description="未获取到基础信息" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-base-info-card__description {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
53
src/views/product/setting/modules/setting-danger-zone.vue
Normal file
53
src/views/product/setting/modules/setting-danger-zone.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SettingDangerZone' });
|
||||
|
||||
interface Props {
|
||||
productName: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'delete'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="setting-danger-zone card-wrapper">
|
||||
<div class="setting-danger-zone__content">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-16px text-[#7f1d1d] font-700">危险操作</h3>
|
||||
<p class="mt-8px text-14px text-[#991b1b] leading-24px">
|
||||
删除后将退出当前产品对象上下文,并返回产品入口页。删除时必须输入当前产品名称
|
||||
<strong>{{ productName || '--' }}</strong>
|
||||
进行二次确认。
|
||||
</p>
|
||||
</div>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:delete', source: 'object' }"
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('delete')"
|
||||
>
|
||||
删除产品
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-danger-zone {
|
||||
border: 1px solid rgb(254 202 202 / 96%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(254 226 226 / 96%), transparent 35%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(254 242 242 / 96%));
|
||||
}
|
||||
|
||||
.setting-danger-zone__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
393
src/views/product/setting/modules/setting-lifecycle-panel.vue
Normal file
393
src/views/product/setting/modules/setting-lifecycle-panel.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getProductStatusLabel } from '../../shared/product-master-data';
|
||||
import { getProductLifecycleActionCardMeta, getProductLifecycleStatusSummary } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingLifecyclePanel' });
|
||||
|
||||
interface Props {
|
||||
lifecycle: Api.Product.ProductLifecycleInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: Api.Product.ProductLifecycleAction): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
if (!props.lifecycle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getProductLifecycleStatusSummary(props.lifecycle.statusCode);
|
||||
});
|
||||
|
||||
const actionCards = computed(() =>
|
||||
(props.lifecycle?.availableActions || []).map(action => ({
|
||||
...action,
|
||||
...getProductLifecycleActionCardMeta(action.actionCode)
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">生命周期管理</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="lifecycle">
|
||||
<div class="setting-lifecycle-panel__layout">
|
||||
<section
|
||||
class="setting-lifecycle-panel__hero"
|
||||
:class="[`setting-lifecycle-panel__hero--${statusSummary?.tone || 'slate'}`]"
|
||||
>
|
||||
<div class="setting-lifecycle-panel__hero-top">
|
||||
<div class="setting-lifecycle-panel__hero-main">
|
||||
<div class="setting-lifecycle-panel__hero-status-row">
|
||||
<span class="setting-lifecycle-panel__hero-status-label">当前状态</span>
|
||||
<span class="setting-lifecycle-panel__hero-status-chip">
|
||||
{{ getProductStatusLabel(lifecycle.statusCode) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="setting-lifecycle-panel__hero-title">{{ statusSummary?.caption }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="setting-lifecycle-panel__hero-desc">
|
||||
{{ statusSummary?.description }}
|
||||
</p>
|
||||
|
||||
<div class="setting-lifecycle-panel__reason-card">
|
||||
<span class="setting-lifecycle-panel__reason-label">最近状态原因</span>
|
||||
<strong class="setting-lifecycle-panel__reason-value">
|
||||
{{ lifecycle.lastStatusReason || '当前没有记录状态原因。' }}
|
||||
</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="setting-lifecycle-panel__action-panel">
|
||||
<div class="setting-lifecycle-panel__action-head">
|
||||
<h4 class="setting-lifecycle-panel__action-title">可执行动作</h4>
|
||||
</div>
|
||||
|
||||
<div v-if="actionCards.length > 0" class="setting-lifecycle-panel__action-grid">
|
||||
<button
|
||||
v-for="action in actionCards"
|
||||
:key="action.actionCode"
|
||||
type="button"
|
||||
class="setting-lifecycle-panel__action-card"
|
||||
:class="[`setting-lifecycle-panel__action-card--${action.tone}`]"
|
||||
@click="emit('action', action)"
|
||||
>
|
||||
<div class="setting-lifecycle-panel__action-card-top">
|
||||
<span class="setting-lifecycle-panel__action-dot" aria-hidden="true"></span>
|
||||
<strong class="setting-lifecycle-panel__action-name">{{ action.actionName }}</strong>
|
||||
</div>
|
||||
<p class="setting-lifecycle-panel__action-desc">{{ action.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="setting-lifecycle-panel__empty-tip">当前状态下暂无可执行生命周期动作。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-else description="未获取到生命周期信息" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-lifecycle-panel__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero,
|
||||
.setting-lifecycle-panel__action-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 20px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--emerald {
|
||||
border-color: rgb(16 185 129 / 22%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--amber {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(245 158 11 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 251 235 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--slate {
|
||||
border-color: rgb(100 116 139 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(100 116 139 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--rose {
|
||||
border-color: rgb(244 63 94 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(244 63 94 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 241 242 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-top,
|
||||
.setting-lifecycle-panel__action-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-label {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-desc {
|
||||
max-width: 560px;
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 82%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-value {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-panel {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(59 130 246 / 7%), transparent 32%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 96%);
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgb(15 23 42 / 6%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background-color: currentcolor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-name {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-desc {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__empty-tip {
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed rgb(203 213 225 / 92%);
|
||||
border-radius: 16px;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--emerald .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(16 185 129 / 24%);
|
||||
background-color: rgb(236 253 245 / 90%);
|
||||
color: rgb(4 120 87 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--amber .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(245 158 11 / 24%);
|
||||
background-color: rgb(255 247 237 / 94%);
|
||||
color: rgb(180 83 9 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--slate .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(148 163 184 / 28%);
|
||||
background-color: rgb(241 245 249 / 94%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--rose .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(244 63 94 / 24%);
|
||||
background-color: rgb(255 241 242 / 94%);
|
||||
color: rgb(190 24 93 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald {
|
||||
border-color: rgb(16 185 129 / 22%);
|
||||
background: linear-gradient(90deg, rgb(236 253 245 / 90%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(4 120 87 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
background: linear-gradient(90deg, rgb(255 247 237 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(180 83 9 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate {
|
||||
border-color: rgb(148 163 184 / 26%);
|
||||
background: linear-gradient(90deg, rgb(241 245 249 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose {
|
||||
border-color: rgb(244 63 94 / 22%);
|
||||
background: linear-gradient(90deg, rgb(255 241 242 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(190 24 93 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald:hover {
|
||||
box-shadow: 0 10px 22px rgb(16 185 129 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber:hover {
|
||||
box-shadow: 0 10px 22px rgb(245 158 11 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate:hover {
|
||||
box-shadow: 0 10px 22px rgb(100 116 139 / 10%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose:hover {
|
||||
box-shadow: 0 10px 22px rgb(244 63 94 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald .setting-lifecycle-panel__action-name {
|
||||
color: rgb(6 95 70 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber .setting-lifecycle-panel__action-name {
|
||||
color: rgb(146 64 14 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate .setting-lifecycle-panel__action-name {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose .setting-lifecycle-panel__action-name {
|
||||
color: rgb(159 18 57 / 96%);
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.setting-lifecycle-panel__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.setting-lifecycle-panel__hero-top,
|
||||
.setting-lifecycle-panel__action-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
src/views/product/setting/modules/setting-team-panel.vue
Normal file
201
src/views/product/setting/modules/setting-team-panel.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingTeamPanel' });
|
||||
|
||||
interface Props {
|
||||
members: Api.Product.ProductMember[];
|
||||
roleOptions?: Api.SystemManage.RoleSimple[];
|
||||
loading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', member: Api.Product.ProductMember): void;
|
||||
(e: 'remove', member: Api.Product.ProductMember): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
readonly: false,
|
||||
roleOptions: () => []
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const searchKeyword = ref('');
|
||||
const selectedRoleId = ref('');
|
||||
const teamTableHeight = getProductTeamTableHeight(5);
|
||||
const roleFilterOptions = computed(() => {
|
||||
const roleMap = new Map<string, string>();
|
||||
|
||||
props.roleOptions.forEach(role => {
|
||||
if (!roleMap.has(role.id)) {
|
||||
roleMap.set(role.id, role.name);
|
||||
}
|
||||
});
|
||||
|
||||
return [...roleMap.entries()].map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}));
|
||||
});
|
||||
const filteredMembers = computed(() =>
|
||||
filterProductMembers(props.members, {
|
||||
keyword: searchKeyword.value,
|
||||
roleId: selectedRoleId.value
|
||||
})
|
||||
);
|
||||
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
||||
|
||||
watch(roleFilterOptions, options => {
|
||||
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
|
||||
selectedRoleId.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
function getMemberStatusLabel(status: Api.Product.ProductMemberStatus) {
|
||||
return status === 0 ? '有效' : '失效';
|
||||
}
|
||||
|
||||
function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
|
||||
return status === 0 ? 'success' : 'info';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div class="setting-team-panel__header">
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">团队管理</h3>
|
||||
</div>
|
||||
<div class="setting-team-panel__toolbar">
|
||||
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
|
||||
<ElOption
|
||||
v-for="option in roleFilterOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
||||
<ElButton
|
||||
v-if="!props.readonly"
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
type="primary"
|
||||
plain
|
||||
@click="emit('create')"
|
||||
>
|
||||
新增成员
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTable
|
||||
v-loading="props.loading"
|
||||
:data="filteredMembers"
|
||||
:height="teamTableHeight"
|
||||
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
||||
border
|
||||
row-key="id"
|
||||
>
|
||||
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
||||
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
|
||||
<ElTableColumn label="成员状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="joinedTime" label="加入时间" min-width="132" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatProductMemberDate(row.joinedTime) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="leftTime" label="退出时间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatProductMemberDate(row.leftTime) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.remark || '--' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!props.readonly" label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="setting-team-panel__actions">
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="row.status !== 0 || row.managerFlag"
|
||||
@click="emit('edit', row)"
|
||||
>
|
||||
调整角色
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
link
|
||||
type="danger"
|
||||
:disabled="row.status !== 0 || row.managerFlag"
|
||||
@click="emit('remove', row)"
|
||||
>
|
||||
移出成员
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-team-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-team-panel__toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-team-panel__search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.setting-team-panel__role-filter {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.setting-team-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.setting-team-panel__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-team-panel__toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-team-panel__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-team-panel__role-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
src/views/product/setting/modules/status-action-dialog.vue
Normal file
73
src/views/product/setting/modules/status-action-dialog.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'StatusActionDialog' });
|
||||
|
||||
interface Props {
|
||||
action: Api.Product.ProductLifecycleAction | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.ChangeProductStatusParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => Boolean(props.action?.needReason && !model.reason.trim()));
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
id: '',
|
||||
actionCode: props.action.actionCode,
|
||||
reason: model.reason.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="action ? `${action.actionName}产品` : '生命周期动作'"
|
||||
preset="sm"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem :label="action?.needReason ? '动作原因(必填)' : '动作原因(选填)'">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入动作原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
233
src/views/product/setting/shared.ts
Normal file
233
src/views/product/setting/shared.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export interface ProductManagerMemberLike {
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
interface ProductTeamManageContext {
|
||||
buttonCodes: readonly string[];
|
||||
loginUserId: string | null | undefined;
|
||||
currentManagerUserId: string | null | undefined;
|
||||
}
|
||||
|
||||
interface ProductLifecycleStatusSummary {
|
||||
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||
caption: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ProductLifecycleActionCardMeta {
|
||||
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const productSettingSectionKeys = ['base-info', 'team', 'lifecycle', 'danger'] as const;
|
||||
|
||||
export type ProductSettingSectionKey = (typeof productSettingSectionKeys)[number];
|
||||
|
||||
const productSettingSectionAuthCodeMap: Partial<Record<ProductSettingSectionKey, string>> = {
|
||||
lifecycle: 'project:product:status',
|
||||
danger: 'project:product:delete'
|
||||
};
|
||||
|
||||
const productBaseInfoReadonlyMessageMap: Partial<Record<Api.Product.ProductStatusCode, string>> = {
|
||||
paused: '当前产品已暂停,基础信息仅支持查看,不可编辑。',
|
||||
archived: '当前产品已归档,基础信息仅支持查看,不可编辑。',
|
||||
abandoned: '当前产品已废弃,基础信息仅支持查看,不可编辑。'
|
||||
};
|
||||
|
||||
const productLifecycleStatusSummaryMap: Record<Api.Product.ProductStatusCode, ProductLifecycleStatusSummary> = {
|
||||
active: {
|
||||
tone: 'emerald',
|
||||
caption: '产品正常服务中',
|
||||
description: '当前可以执行暂停、归档或废弃。'
|
||||
},
|
||||
paused: {
|
||||
tone: 'amber',
|
||||
caption: '产品已暂停推进',
|
||||
description: '条件恢复后可重新启用,也可继续归档或废弃。'
|
||||
},
|
||||
archived: {
|
||||
tone: 'slate',
|
||||
caption: '产品已收口归档',
|
||||
description: '保留历史信息,当前不再开放新的生命周期动作。'
|
||||
},
|
||||
abandoned: {
|
||||
tone: 'rose',
|
||||
caption: '产品已停止建设',
|
||||
description: '产品已结束推进,当前不再开放新的生命周期动作。'
|
||||
}
|
||||
};
|
||||
|
||||
const productLifecycleActionCardMetaMap: Record<Api.Product.ProductStatusActionCode, ProductLifecycleActionCardMeta> = {
|
||||
pause: {
|
||||
tone: 'amber',
|
||||
description: '暂停当前产品,后续仍可恢复或归档。'
|
||||
},
|
||||
resume: {
|
||||
tone: 'emerald',
|
||||
description: '恢复启用后,继续推进产品协作。'
|
||||
},
|
||||
archive: {
|
||||
tone: 'slate',
|
||||
description: '收口当前产品,保留历史记录并结束维护。'
|
||||
},
|
||||
abandon: {
|
||||
tone: 'rose',
|
||||
description: '终止当前产品建设,请谨慎确认。'
|
||||
}
|
||||
};
|
||||
|
||||
const productSettingErrorMessageMap: Record<string, string> = {
|
||||
'1008001002': '产品名称已存在,请更换名称',
|
||||
'1008001007': '当前产品状态不允许编辑基础信息',
|
||||
'1008001008': '当前产品已暂停,基础信息仅支持查看,不可编辑。',
|
||||
'1008001013': '请选择原产品经理交接后的角色',
|
||||
'1008001014': '当前产品经理不能直接移出,请先完成经理交接',
|
||||
'1008001015': '当前产品经理不能直接调整为非经理角色,请先完成经理转交',
|
||||
'1008001004': '当前状态不支持该动作',
|
||||
'1008001005': '当前动作必须填写原因',
|
||||
'1008001006': '删除确认名称与当前产品名称不一致'
|
||||
};
|
||||
|
||||
const productTeamTableHeaderHeight = 40;
|
||||
const productTeamTableRowHeight = 40;
|
||||
|
||||
export function shouldRequireManagerHandover(
|
||||
targetRoleId: string,
|
||||
currentManager: ProductManagerMemberLike | null | undefined
|
||||
) {
|
||||
if (!currentManager?.roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return targetRoleId === currentManager.roleId;
|
||||
}
|
||||
|
||||
export function getPreviousManagerRoleOptions(roleOptions: Api.SystemManage.RoleSimple[], managerRoleId: string) {
|
||||
return roleOptions.filter(role => role.id !== managerRoleId);
|
||||
}
|
||||
|
||||
export function getProductSettingSectionKeys() {
|
||||
return [...productSettingSectionKeys];
|
||||
}
|
||||
|
||||
export function isProductBaseInfoEditable(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
return status === 'active';
|
||||
}
|
||||
|
||||
export function resolveVisibleProductSettingSections(
|
||||
sectionKeys: readonly ProductSettingSectionKey[],
|
||||
buttonCodes: readonly string[]
|
||||
) {
|
||||
return sectionKeys.filter(sectionKey => {
|
||||
const authCode = productSettingSectionAuthCodeMap[sectionKey];
|
||||
|
||||
if (!authCode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return buttonCodes.includes(authCode);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVisibleProductSettingSectionKey(
|
||||
currentKey: ProductSettingSectionKey | string | null | undefined,
|
||||
visibleSectionKeys: readonly ProductSettingSectionKey[]
|
||||
) {
|
||||
if (!visibleSectionKeys.length) {
|
||||
return 'base-info' satisfies ProductSettingSectionKey;
|
||||
}
|
||||
|
||||
if (currentKey && visibleSectionKeys.includes(currentKey as ProductSettingSectionKey)) {
|
||||
return currentKey as ProductSettingSectionKey;
|
||||
}
|
||||
|
||||
return visibleSectionKeys[0];
|
||||
}
|
||||
|
||||
export function getProductBaseInfoReadonlyMessage(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
if (!status || isProductBaseInfoEditable(status)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return productBaseInfoReadonlyMessageMap[status] || '当前产品状态不允许编辑基础信息。';
|
||||
}
|
||||
|
||||
export function getProductLifecycleStatusSummary(status: Api.Product.ProductStatusCode) {
|
||||
return productLifecycleStatusSummaryMap[status];
|
||||
}
|
||||
|
||||
export function formatProductMemberDate(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const normalizedValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
||||
const parsedDate = dayjs(normalizedValue);
|
||||
|
||||
if (!parsedDate.isValid()) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return parsedDate.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
export function filterProductMembersByKeyword(
|
||||
members: readonly Api.Product.ProductMember[],
|
||||
keyword: string | null | undefined
|
||||
) {
|
||||
return filterProductMembers(members, { keyword });
|
||||
}
|
||||
|
||||
export function filterProductMembers(
|
||||
members: readonly Api.Product.ProductMember[],
|
||||
filters: {
|
||||
keyword?: string | null | undefined;
|
||||
roleId?: string | null | undefined;
|
||||
}
|
||||
) {
|
||||
const normalizedKeyword = String(filters.keyword || '')
|
||||
.trim()
|
||||
.toLocaleLowerCase();
|
||||
const normalizedRoleId = String(filters.roleId || '').trim();
|
||||
|
||||
if (!normalizedKeyword && !normalizedRoleId) {
|
||||
return [...members];
|
||||
}
|
||||
|
||||
return members.filter(member => {
|
||||
const matchesKeyword = !normalizedKeyword || member.userNickname.toLocaleLowerCase().includes(normalizedKeyword);
|
||||
const matchesRole = !normalizedRoleId || member.roleId === normalizedRoleId;
|
||||
|
||||
return matchesKeyword && matchesRole;
|
||||
});
|
||||
}
|
||||
|
||||
export function getProductTeamTableHeight(visibleRows: number) {
|
||||
const normalizedRows = Math.max(0, visibleRows);
|
||||
|
||||
return productTeamTableHeaderHeight + normalizedRows * productTeamTableRowHeight;
|
||||
}
|
||||
|
||||
export function getProductLifecycleActionCardMeta(actionCode: Api.Product.ProductStatusActionCode) {
|
||||
return productLifecycleActionCardMetaMap[actionCode];
|
||||
}
|
||||
|
||||
export function canManageProductTeam(context: ProductTeamManageContext) {
|
||||
const hasUpdateAuth = context.buttonCodes.includes('project:product:update');
|
||||
const loginUserId = String(context.loginUserId || '');
|
||||
const currentManagerUserId = String(context.currentManagerUserId || '');
|
||||
|
||||
if (!hasUpdateAuth || !loginUserId || !currentManagerUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return loginUserId === currentManagerUserId;
|
||||
}
|
||||
|
||||
export function getProductSettingErrorMessage(code: string | number | null | undefined, backendMessage: string) {
|
||||
const normalizedCode = String(code || '');
|
||||
|
||||
return productSettingErrorMessageMap[normalizedCode] || backendMessage;
|
||||
}
|
||||
115
src/views/product/shared/product-context-banner.vue
Normal file
115
src/views/product/shared/product-context-banner.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from './product-master-data';
|
||||
import type { CurrentProductSummary } from './product-context-shared';
|
||||
|
||||
defineOptions({ name: 'ProductContextBanner' });
|
||||
|
||||
interface Props {
|
||||
product: CurrentProductSummary | null;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const productStatusCode = computed(() => props.product?.statusCode as Api.Product.ProductStatusCode | undefined);
|
||||
|
||||
const summaryItems = computed(() => {
|
||||
if (!props.product) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: '产品 ID', value: props.product.id || '--' },
|
||||
{ label: '产品编码', value: props.product.code || '--' },
|
||||
{ label: '产品方向', value: getDirectionLabel(props.product.directionCode, '--') },
|
||||
{ label: '产品经理', value: props.product.managerUserId || '--' }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="product-context-banner card-wrapper">
|
||||
<template v-if="product">
|
||||
<div class="flex flex-col gap-20px lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-12px flex flex-wrap items-center gap-10px">
|
||||
<span class="product-context-banner__code">{{ product.code }}</span>
|
||||
<ElTag :type="getProductStatusTagType(productStatusCode!)" effect="light" round>
|
||||
{{ getProductStatusLabel(productStatusCode!) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="mb-10px flex flex-wrap items-center gap-12px">
|
||||
<h2 class="text-24px text-[#0f172a] font-700">{{ product.name }}</h2>
|
||||
<span class="text-14px text-[#64748b]">{{ caption }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-18px gap-y-8px text-13px text-[#64748b] leading-22px">
|
||||
<span>对象 ID:{{ product.id || '--' }}</span>
|
||||
<span>方向:{{ getDirectionLabel(product.directionCode, '--') }}</span>
|
||||
<span>产品经理:{{ product.managerUserId || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-context-banner__stats">
|
||||
<div v-for="item in summaryItems" :key="item.label" class="product-context-banner__stat-card">
|
||||
<span class="product-context-banner__stat-label">{{ item.label }}</span>
|
||||
<strong class="product-context-banner__stat-value">{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ElEmpty v-else description="未获取到当前产品上下文" :image-size="84" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-context-banner {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(14 165 233 / 10%), transparent 32%),
|
||||
linear-gradient(135deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
|
||||
}
|
||||
|
||||
.product-context-banner__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(15 23 42 / 88%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.product-context-banner__stats {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
grid-template-columns: repeat(2, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.product-context-banner__stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgb(148 163 184 / 18%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 72%);
|
||||
}
|
||||
|
||||
.product-context-banner__stat-label {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-context-banner__stat-value {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
43
src/views/product/shared/product-context-shared.ts
Normal file
43
src/views/product/shared/product-context-shared.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface CurrentProductSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
directionCode: string;
|
||||
name: string;
|
||||
managerUserId: string;
|
||||
statusCode: string;
|
||||
}
|
||||
|
||||
export function resolveObjectIdFromQuery(
|
||||
routeObjectId: string | null | Array<string | null> | undefined,
|
||||
fallbackObjectId: string
|
||||
) {
|
||||
if (Array.isArray(routeObjectId)) {
|
||||
return String(routeObjectId[0] || fallbackObjectId || '');
|
||||
}
|
||||
|
||||
if (routeObjectId === null || routeObjectId === undefined || routeObjectId === '') {
|
||||
return fallbackObjectId;
|
||||
}
|
||||
|
||||
return String(routeObjectId);
|
||||
}
|
||||
|
||||
export function normalizeCurrentProductSummary(
|
||||
objectSummary: App.ObjectContext.Summary | null | undefined,
|
||||
objectName: string
|
||||
): CurrentProductSummary | null {
|
||||
const currentProduct = objectSummary?.currentProduct;
|
||||
|
||||
if (!currentProduct || typeof currentProduct !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String((currentProduct as Record<string, unknown>).id || ''),
|
||||
code: String((currentProduct as Record<string, unknown>).code || ''),
|
||||
directionCode: String((currentProduct as Record<string, unknown>).directionCode || ''),
|
||||
name: String((currentProduct as Record<string, unknown>).name || objectName || ''),
|
||||
managerUserId: String((currentProduct as Record<string, unknown>).managerUserId || ''),
|
||||
statusCode: String((currentProduct as Record<string, unknown>).statusCode || '')
|
||||
};
|
||||
}
|
||||
68
src/views/product/shared/product-master-data.ts
Normal file
68
src/views/product/shared/product-master-data.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { transformRecordToOption } from '@/utils/common';
|
||||
|
||||
export const productStatusRecord: Record<Api.Product.ProductStatusCode, string> = {
|
||||
active: '启用',
|
||||
paused: '暂停',
|
||||
archived: '归档',
|
||||
abandoned: '废弃'
|
||||
};
|
||||
|
||||
export const productStatusOptions = transformRecordToOption(productStatusRecord);
|
||||
|
||||
export const productStatusActionRecord: Record<Api.Product.ProductStatusActionCode, string> = {
|
||||
pause: '暂停产品',
|
||||
resume: '恢复产品',
|
||||
archive: '归档产品',
|
||||
abandon: '废弃产品'
|
||||
};
|
||||
|
||||
export function getProductStatusLabel(status: Api.Product.ProductStatusCode) {
|
||||
return productStatusRecord[status];
|
||||
}
|
||||
|
||||
export function getProductStatusTagType(status: Api.Product.ProductStatusCode): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<Api.Product.ProductStatusCode, UI.ThemeColor> = {
|
||||
active: 'success',
|
||||
paused: 'warning',
|
||||
archived: 'info',
|
||||
abandoned: 'danger'
|
||||
};
|
||||
|
||||
return statusTagTypeMap[status];
|
||||
}
|
||||
|
||||
export function isProductEditable(status: Api.Product.ProductStatusCode) {
|
||||
return status === 'active' || status === 'paused';
|
||||
}
|
||||
|
||||
export function isProductEditLimited(status: Api.Product.ProductStatusCode) {
|
||||
return status === 'paused';
|
||||
}
|
||||
|
||||
export function getAllowedProductStatusActions(
|
||||
status: Api.Product.ProductStatusCode
|
||||
): Api.Product.ProductStatusActionCode[] {
|
||||
const actionMap: Record<Api.Product.ProductStatusCode, Api.Product.ProductStatusActionCode[]> = {
|
||||
active: ['pause', 'archive', 'abandon'],
|
||||
paused: ['resume', 'archive', 'abandon'],
|
||||
archived: [],
|
||||
abandoned: []
|
||||
};
|
||||
|
||||
return actionMap[status];
|
||||
}
|
||||
|
||||
export function getProductStatusActionLabel(actionCode: Api.Product.ProductStatusActionCode) {
|
||||
return productStatusActionRecord[actionCode];
|
||||
}
|
||||
|
||||
export function getProductStatusActionOptions(status: Api.Product.ProductStatusCode) {
|
||||
return getAllowedProductStatusActions(status).map(actionCode => ({
|
||||
value: actionCode,
|
||||
label: getProductStatusActionLabel(actionCode)
|
||||
}));
|
||||
}
|
||||
|
||||
export function isProductActionReasonRequired(actionCode: Api.Product.ProductStatusActionCode) {
|
||||
return actionCode !== 'resume';
|
||||
}
|
||||
23
src/views/product/shared/use-current-product.ts
Normal file
23
src/views/product/shared/use-current-product.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { normalizeCurrentProductSummary, resolveObjectIdFromQuery } from './product-context-shared';
|
||||
|
||||
export function useCurrentProduct() {
|
||||
const route = useRoute();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
const currentObjectId = computed(() => {
|
||||
return resolveObjectIdFromQuery(route.query[OBJECT_CONTEXT_QUERY_KEY], objectContextStore.objectId);
|
||||
});
|
||||
|
||||
const currentProduct = computed(() =>
|
||||
normalizeCurrentProductSummary(objectContextStore.objectSummary, objectContextStore.objectName)
|
||||
);
|
||||
|
||||
return {
|
||||
currentObjectId,
|
||||
currentProduct
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { menuRouteKindRecord, menuTypeRecord } from '@/constants/business';
|
||||
import {
|
||||
commonStatusRecord,
|
||||
menuRouteKindRecord,
|
||||
menuTypeRecord,
|
||||
objectTypeRecord,
|
||||
scopeTypeRecord
|
||||
} from '@/constants/business';
|
||||
import { fetchBatchDeleteMenu, fetchDeleteMenu, fetchGetMenuList } from '@/service/api';
|
||||
import { useUITable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import MenuContextPanel from './modules/menu-context-panel.vue';
|
||||
import MenuIconCell from './modules/menu-icon-cell';
|
||||
import MenuOperateDialog, { type OperateType } from './modules/menu-operate-dialog.vue';
|
||||
import MenuOperateCell from './modules/menu-operate-cell';
|
||||
@@ -32,6 +39,28 @@ function getMenuTypeTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
function getStatusTagType(status: Api.SystemManage.CommonStatus): UI.ThemeColor {
|
||||
return status === 0 ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
return $t(commonStatusRecord[status]);
|
||||
}
|
||||
|
||||
function getMenuTypeLabel(type: Api.SystemManage.MenuType, currentScopeType: Api.SystemManage.ScopeType) {
|
||||
if (currentScopeType === 'object') {
|
||||
if (type === 2) {
|
||||
return $t('page.system.menu.type.navigation');
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
return $t('page.system.menu.type.actionButton');
|
||||
}
|
||||
}
|
||||
|
||||
return $t(menuTypeRecord[type]);
|
||||
}
|
||||
|
||||
function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
if (!routeKind) {
|
||||
return '--';
|
||||
@@ -42,9 +71,30 @@ function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const flatMenuList = ref<Api.SystemManage.Menu[]>([]);
|
||||
const scopeType = ref<Api.SystemManage.ScopeType>('global');
|
||||
const objectType = ref<Api.SystemManage.ObjectType>('product');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (scopeType.value === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: objectType.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
api: () => fetchGetMenuList(searchParams),
|
||||
api: () =>
|
||||
fetchGetMenuList({
|
||||
...searchParams,
|
||||
...getCurrentScopeParams()
|
||||
}),
|
||||
transform: response => {
|
||||
if (!response.error) {
|
||||
flatMenuList.value = response.data;
|
||||
@@ -56,13 +106,20 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 220, showOverflowTooltip: true },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 240, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'type',
|
||||
label: $t('page.system.menu.menuType'),
|
||||
width: 96,
|
||||
width: 108,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{$t(menuTypeRecord[row.type])}</ElTag>
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{getMenuTypeLabel(row.type, scopeType.value)}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.menu.menuStatus'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'icon',
|
||||
@@ -73,6 +130,7 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
return <MenuIconCell icon={row.icon ?? ''} />;
|
||||
}
|
||||
},
|
||||
{ prop: 'sort', label: $t('page.system.menu.order'), width: 88, align: 'center' },
|
||||
{ prop: 'permission', label: $t('page.system.menu.permission'), minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'path', label: $t('page.system.menu.routePath'), minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
@@ -99,11 +157,23 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
const { bool: visible, setTrue: openModal, setFalse: closeModal } = useBoolean();
|
||||
const operateType = ref<OperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.Menu | null>(null);
|
||||
const checkedRowKeys = ref<number[]>([]);
|
||||
const checkedRowKeys = ref<string[]>([]);
|
||||
const tableRef = ref<TableInstance>();
|
||||
const tableRenderKey = ref(0);
|
||||
|
||||
const allMenus = computed(() => flatMenuList.value);
|
||||
const currentScopeLabel = computed(() => $t(scopeTypeRecord[scopeType.value]));
|
||||
const currentObjectTypeLabel = computed(() => {
|
||||
return objectType.value ? $t(objectTypeRecord[objectType.value]) : '';
|
||||
});
|
||||
const currentContextTagLabel = computed(() => {
|
||||
return isObjectScope.value && currentObjectTypeLabel.value
|
||||
? `${currentScopeLabel.value} / ${currentObjectTypeLabel.value}`
|
||||
: currentScopeLabel.value;
|
||||
});
|
||||
const currentTableTitle = computed(() => {
|
||||
return $t(isObjectScope.value ? 'page.system.menu.objectResourceTitle' : 'page.system.menu.globalResourceTitle');
|
||||
});
|
||||
const expandedRowKeys = computed(() => {
|
||||
const firstRootMenu = data.value[0];
|
||||
|
||||
@@ -140,7 +210,7 @@ function openEdit(item: Api.SystemManage.Menu) {
|
||||
openModal();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
async function handleDelete(id: string) {
|
||||
const { error } = await fetchDeleteMenu(id);
|
||||
|
||||
if (error) {
|
||||
@@ -193,27 +263,83 @@ async function handleSubmitted() {
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
watch(scopeType, value => {
|
||||
if (value === 'object' && !objectType.value) {
|
||||
objectType.value = 'product';
|
||||
}
|
||||
});
|
||||
|
||||
let contextChangeToken = 0;
|
||||
|
||||
watch([scopeType, objectType], async ([nextScope, nextObject], [prevScope, prevObject]) => {
|
||||
if (nextScope === prevScope && nextObject === prevObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextChangeToken += 1;
|
||||
const token = contextChangeToken;
|
||||
await nextTick();
|
||||
|
||||
if (token !== contextChangeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<MenuContextPanel
|
||||
v-model:scope-type="scopeType"
|
||||
v-model:object-type="objectType"
|
||||
:total="flatMenuList.length"
|
||||
:loading="loading"
|
||||
/>
|
||||
|
||||
<MenuSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper sm:flex-1-hidden" body-class="menu-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<p>{{ $t('page.system.menu.title') }}</p>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
<div class="menu-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">{{ currentTableTitle }}</p>
|
||||
<ElTag effect="plain" :type="isObjectScope ? 'success' : 'primary'">{{ currentContextTagLabel }}</ElTag>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="checkedRowKeys.length === 0"
|
||||
:loading="loading"
|
||||
@add="openAdd"
|
||||
@delete="handleBatchDelete"
|
||||
@refresh="reloadTable"
|
||||
/>
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleBatchDelete">
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="checkedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -238,6 +364,8 @@ async function handleSubmitted() {
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:all-menus="allMenus"
|
||||
:scope-type="scopeType"
|
||||
:object-type="objectType"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
@@ -249,4 +377,18 @@ async function handleSubmitted() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.menu-card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
249
src/views/system/menu/modules/menu-context-panel.vue
Normal file
249
src/views/system/menu/modules/menu-context-panel.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuContextPanel' });
|
||||
|
||||
interface Props {
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
total: 0,
|
||||
loading: false
|
||||
});
|
||||
|
||||
const scopeType = defineModel<Api.SystemManage.ScopeType>('scopeType', {
|
||||
required: true
|
||||
});
|
||||
|
||||
const objectType = defineModel<Api.SystemManage.ObjectType | undefined>('objectType');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
const scopeOptions = computed(() => [
|
||||
{ label: $t(scopeTypeRecord.global), value: 'global' satisfies Api.SystemManage.ScopeType },
|
||||
{ label: $t(scopeTypeRecord.object), value: 'object' satisfies Api.SystemManage.ScopeType }
|
||||
]);
|
||||
|
||||
const objectTypeOptions = computed(() => [
|
||||
{ label: $t(objectTypeRecord.product), value: 'product' satisfies Api.SystemManage.ObjectType },
|
||||
{ label: $t(objectTypeRecord.project), value: 'project' satisfies Api.SystemManage.ObjectType }
|
||||
]);
|
||||
|
||||
const currentContextLabel = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t(scopeTypeRecord.global);
|
||||
}
|
||||
|
||||
if (!objectType.value) {
|
||||
return `${$t(scopeTypeRecord.object)} / --`;
|
||||
}
|
||||
|
||||
return `${$t(scopeTypeRecord.object)} / ${$t(objectTypeRecord[objectType.value])}`;
|
||||
});
|
||||
|
||||
const currentScopeSummary = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t('page.system.menu.globalResourceSummary');
|
||||
}
|
||||
|
||||
if (objectType.value === 'product') {
|
||||
return $t('page.system.menu.objectResourceSummaryProduct');
|
||||
}
|
||||
|
||||
if (objectType.value === 'project') {
|
||||
return $t('page.system.menu.objectResourceSummaryProject');
|
||||
}
|
||||
|
||||
return $t('page.system.menu.objectResourceSummary');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="menu-context-panel" body-class="menu-context-panel__body">
|
||||
<div v-loading="props.loading" class="menu-context-panel__layout">
|
||||
<div class="menu-context-panel__controls">
|
||||
<div class="menu-context-panel__field menu-context-panel__field--switch">
|
||||
<ElSegmented v-model="scopeType" :options="scopeOptions" />
|
||||
</div>
|
||||
|
||||
<span v-if="isObjectScope" class="menu-context-panel__divider" aria-hidden="true">|</span>
|
||||
|
||||
<div v-if="isObjectScope" class="menu-context-panel__field menu-context-panel__field--inline">
|
||||
<span class="menu-context-panel__field-label menu-context-panel__field-label--inline">
|
||||
{{ $t('page.system.menu.objectType') }}
|
||||
</span>
|
||||
<ElSelect v-model="objectType" class="w-full" :placeholder="$t('page.system.menu.objectTypePlaceholder')">
|
||||
<ElOption v-for="item in objectTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-context-panel__info">
|
||||
<div class="menu-context-panel__info-main">
|
||||
<div class="menu-context-panel__info-item">
|
||||
<span class="menu-context-panel__info-label">{{ $t('page.system.menu.currentContext') }}</span>
|
||||
<strong class="menu-context-panel__info-value">{{ currentContextLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="menu-context-panel__info-item">
|
||||
<span class="menu-context-panel__info-label">{{ $t('page.system.menu.currentResourceCount') }}</span>
|
||||
<strong class="menu-context-panel__info-value">{{ props.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="menu-context-panel__info-desc">{{ currentScopeSummary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-context-panel {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__body) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.menu-context-panel__layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-context-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.menu-context-panel__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-context-panel__field--switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--switch .el-segmented) {
|
||||
width: auto;
|
||||
padding: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--switch .el-segmented__item) {
|
||||
min-height: 40px;
|
||||
min-width: 96px;
|
||||
padding: 0 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-context-panel__field-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-context-panel__field--inline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__field-label--inline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__divider {
|
||||
color: var(--el-border-color-darker);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--inline .el-select) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-desc {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.menu-context-panel__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.menu-context-panel__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.menu-context-panel__info {
|
||||
padding-left: 0;
|
||||
padding-top: 14px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.menu-context-panel__controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { menuRouteKindOptions, menuTypeOptions } from '@/constants/business';
|
||||
import type { ElegantConstRoute } from '@elegant-router/types';
|
||||
import { commonStatusOptions, menuRouteKindOptions } from '@/constants/business';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import { fetchCreateMenu, fetchGetMenu, fetchUpdateMenu } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { createStaticRoutes } from '@/router/routes';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { $t } from '@/locales';
|
||||
@@ -18,6 +21,8 @@ interface Props {
|
||||
operateType: OperateType;
|
||||
rowData?: Api.SystemManage.Menu | null;
|
||||
allMenus: Api.SystemManage.Menu[];
|
||||
scopeType: Api.SystemManage.ScopeType;
|
||||
objectType?: Api.SystemManage.ObjectType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -32,8 +37,6 @@ const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
type PageResourceItem = (typeof frontendPageResourceManifest.items)[number];
|
||||
|
||||
type Model = Api.SystemManage.SaveMenuParams & {
|
||||
pageResourcePath: string;
|
||||
iframeUrl: string;
|
||||
@@ -48,12 +51,21 @@ type RuleFormItem = {
|
||||
};
|
||||
|
||||
type ParentTreeOption = {
|
||||
value: number;
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
children?: ParentTreeOption[];
|
||||
};
|
||||
|
||||
type RouteBindingItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
component: string;
|
||||
title: string;
|
||||
keepAlive: boolean;
|
||||
props: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const DIRECTORY_COMPONENT = frontendPageResourceManifest.rules.directoryComponent;
|
||||
const IFRAME_COMPONENT = 'view.iframe-page';
|
||||
|
||||
@@ -62,7 +74,16 @@ const pageResourceItems = frontendPageResourceManifest.items
|
||||
.slice()
|
||||
.sort((prev, next) => prev.path.localeCompare(next.path));
|
||||
|
||||
const pageResourceMap = new Map(pageResourceItems.map(item => [item.path, item]));
|
||||
const globalRouteBindingItems: RouteBindingItem[] = pageResourceItems.map(item => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
component: item.component,
|
||||
title: item.title || item.name,
|
||||
keepAlive: Boolean(item.keepAlive),
|
||||
props: (item.props as Record<string, unknown> | null) ?? null
|
||||
}));
|
||||
|
||||
const staticAuthRoutes = createStaticRoutes().authRoutes;
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
@@ -73,6 +94,7 @@ const initializingModel = ref(false);
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const isAddChild = computed(() => props.operateType === 'addChild');
|
||||
const isObjectScope = computed(() => props.scopeType === 'object');
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<OperateType, string> = {
|
||||
@@ -84,15 +106,32 @@ const title = computed(() => {
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
const dialogWidth = '780px';
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (props.scopeType === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: props.objectType
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
permission: '',
|
||||
scopeType: props.scopeType,
|
||||
objectType: props.scopeType === 'object' ? props.objectType : undefined,
|
||||
type: 2,
|
||||
sort: 0,
|
||||
parentId: 0,
|
||||
parentId: '0',
|
||||
path: '',
|
||||
icon: '',
|
||||
component: '',
|
||||
@@ -171,6 +210,54 @@ function buildComponentNameFromFullPath(fullPath?: string | null) {
|
||||
.join('_');
|
||||
}
|
||||
|
||||
function isPathMatchedByPrefix(path: string, prefix: string) {
|
||||
const normalizedPath = toAbsoluteRoutePath(path);
|
||||
const normalizedPrefix = toAbsoluteRoutePath(prefix);
|
||||
|
||||
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
|
||||
}
|
||||
|
||||
function collectObjectRouteBindingItems(
|
||||
routes: ElegantConstRoute[],
|
||||
config: App.ObjectContext.DomainConfig
|
||||
): RouteBindingItem[] {
|
||||
return routes.flatMap(route => {
|
||||
if (route.children?.length) {
|
||||
return collectObjectRouteBindingItems(route.children, config);
|
||||
}
|
||||
|
||||
const routePath = toAbsoluteRoutePath(route.path);
|
||||
|
||||
if (!routePath || route.name === config.entryRouteKey || routePath === config.entryRoutePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!config.routePathPrefixes.some(prefix => isPathMatchedByPrefix(routePath, prefix))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const component = String(route.component ?? '').trim();
|
||||
|
||||
if (!component.includes('view.')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: String(route.name || routePath),
|
||||
path: routePath,
|
||||
component,
|
||||
title: route.meta?.i18nKey ? $t(route.meta.i18nKey) : String(route.meta?.title || route.name || routePath),
|
||||
keepAlive: Boolean(route.meta?.keepAlive),
|
||||
props:
|
||||
route.props && typeof route.props === 'object' && !Array.isArray(route.props)
|
||||
? (route.props as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function parseRoutePropsJson(value?: string | null) {
|
||||
const text = String(value ?? '').trim();
|
||||
|
||||
@@ -217,13 +304,13 @@ function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function getMenuFullPath(menuId: number) {
|
||||
if (!menuId) {
|
||||
function getMenuFullPath(menuId: string) {
|
||||
if (!menuId || menuId === '0') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const menuMap = new Map(props.allMenus.map(item => [item.id, item]));
|
||||
const visitedIds = new Set<number>();
|
||||
const visitedIds = new Set<string>();
|
||||
const pathSegments: string[] = [];
|
||||
let currentMenu = menuMap.get(menuId);
|
||||
|
||||
@@ -236,7 +323,7 @@ function getMenuFullPath(menuId: number) {
|
||||
pathSegments.unshift(currentPath);
|
||||
}
|
||||
|
||||
if (!currentMenu.parentId) {
|
||||
if (currentMenu.parentId === '0') {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -250,11 +337,18 @@ function getMenuFullPathByData(data: Pick<Api.SystemManage.Menu, 'parentId' | 'p
|
||||
return joinRoutePaths(getMenuFullPath(data.parentId), data.path);
|
||||
}
|
||||
|
||||
function resolvePageResourcePath(data: Api.SystemManage.Menu) {
|
||||
function resolveRouteBindingPath(data: Api.SystemManage.Menu) {
|
||||
const viewComponent = extractViewComponent(data.component);
|
||||
const objectDomainConfig = props.objectType
|
||||
? objectContextDomainConfigs.find(config => config.objectType === props.objectType) || null
|
||||
: null;
|
||||
const candidateItems =
|
||||
isObjectScope.value && objectDomainConfig
|
||||
? collectObjectRouteBindingItems(staticAuthRoutes, objectDomainConfig)
|
||||
: globalRouteBindingItems;
|
||||
|
||||
if (viewComponent) {
|
||||
const matchedByComponent = pageResourceItems.find(item => item.component === viewComponent);
|
||||
const matchedByComponent = candidateItems.find(item => item.component === viewComponent);
|
||||
|
||||
if (matchedByComponent) {
|
||||
return matchedByComponent.path;
|
||||
@@ -263,16 +357,81 @@ function resolvePageResourcePath(data: Api.SystemManage.Menu) {
|
||||
|
||||
const fullPath = getMenuFullPathByData(data);
|
||||
|
||||
return pageResourceItems.find(item => item.path === fullPath)?.path ?? '';
|
||||
return candidateItems.find(item => item.path === fullPath)?.path ?? '';
|
||||
}
|
||||
|
||||
const currentMenuId = computed(() => props.rowData?.id ?? 0);
|
||||
const currentMenuId = computed(() => props.rowData?.id ?? '0');
|
||||
const currentParentFullPath = computed(() => getMenuFullPath(model.value.parentId));
|
||||
const isButton = computed(() => model.value.type === 3);
|
||||
const isMenu = computed(() => model.value.type === 2);
|
||||
const currentObjectDomainConfig = computed(() => {
|
||||
if (!props.objectType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return objectContextDomainConfigs.find(config => config.objectType === props.objectType) || null;
|
||||
});
|
||||
|
||||
const objectRouteBindingItems = computed<RouteBindingItem[]>(() => {
|
||||
if (!isObjectScope.value || !currentObjectDomainConfig.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectObjectRouteBindingItems(staticAuthRoutes, currentObjectDomainConfig.value);
|
||||
});
|
||||
|
||||
const routeBindingItems = computed<RouteBindingItem[]>(() => {
|
||||
return isObjectScope.value ? objectRouteBindingItems.value : globalRouteBindingItems;
|
||||
});
|
||||
|
||||
const routeBindingMap = computed(() => new Map(routeBindingItems.value.map(item => [item.path, item])));
|
||||
const routeBindingOptions = computed(() =>
|
||||
routeBindingItems.value.map(item => ({
|
||||
value: item.path,
|
||||
label: `${item.title || item.name} (${item.path})`
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedRouteBinding = computed(() => routeBindingMap.value.get(model.value.pageResourcePath) ?? null);
|
||||
const routeBindingFieldLabel = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.boundRoute') : $t('page.system.menu.pageResource')
|
||||
);
|
||||
const routeBindingFieldPlaceholder = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource')
|
||||
);
|
||||
const routeBindingFieldTip = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.tips.boundRoute') : $t('page.system.menu.tips.pageResource')
|
||||
);
|
||||
|
||||
const menuTypeRadioOptions = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return [
|
||||
{ value: 1 as Api.SystemManage.MenuType, label: 'page.system.menu.type.directory', disabled: false },
|
||||
{ value: 2 as Api.SystemManage.MenuType, label: 'page.system.menu.type.menu', disabled: false },
|
||||
{ value: 3 as Api.SystemManage.MenuType, label: 'page.system.menu.type.button', disabled: false }
|
||||
];
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ value: 2 as Api.SystemManage.MenuType, label: 'page.system.menu.type.navigation', disabled: false },
|
||||
{ value: 3 as Api.SystemManage.MenuType, label: 'page.system.menu.type.actionButton', disabled: false }
|
||||
];
|
||||
|
||||
if (isEdit.value && model.value.type === 1) {
|
||||
return [
|
||||
{ value: 1 as Api.SystemManage.MenuType, label: 'page.system.menu.type.directory', disabled: true },
|
||||
...options
|
||||
];
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const showRouteFields = computed(() => !isButton.value);
|
||||
const showRouteSection = computed(() => showRouteFields.value);
|
||||
const showPermissionField = computed(() => isButton.value);
|
||||
const showIconField = computed(() => showRouteFields.value);
|
||||
const showRouteKindField = computed(() => showRouteFields.value && !isObjectScope.value);
|
||||
|
||||
const isDirectoryRoute = computed(() => model.value.routeKind === 'dir');
|
||||
const isViewRoute = computed(() => model.value.routeKind === 'view');
|
||||
@@ -296,7 +455,9 @@ const showExternalUrlField = computed(() => isExternalRoute.value);
|
||||
const showRedirectTargetField = computed(() => isRedirectRoute.value);
|
||||
const showReadonlyRouteProps = computed(() => isIframeRoute.value);
|
||||
const showRoutePropsEditor = computed(() => isSingleRoute.value);
|
||||
const canKeepAlive = computed(() => isMenu.value && !isExternalRoute.value && !isRedirectRoute.value);
|
||||
const canKeepAlive = computed(
|
||||
() => !isObjectScope.value && isMenu.value && !isExternalRoute.value && !isRedirectRoute.value
|
||||
);
|
||||
const showDisplaySection = computed(() => canKeepAlive.value);
|
||||
|
||||
const keepAliveSwitch = computed({
|
||||
@@ -314,6 +475,10 @@ const iconFieldValue = computed({
|
||||
});
|
||||
|
||||
const routeKindSelectOptions = computed(() => {
|
||||
if (isObjectScope.value && model.value.type === 2) {
|
||||
return menuRouteKindOptions.filter(item => item.value === 'view');
|
||||
}
|
||||
|
||||
if (model.value.type === 1) {
|
||||
return menuRouteKindOptions.filter(item => item.value === 'dir');
|
||||
}
|
||||
@@ -338,14 +503,11 @@ const routeKindTipItems = computed(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const pageResourceOptions = pageResourceItems.map(item => ({
|
||||
value: item.path,
|
||||
label: `${item.title || item.name} (${item.path})`
|
||||
}));
|
||||
|
||||
const selectedPageResource = computed(() => pageResourceMap.get(model.value.pageResourcePath) ?? null);
|
||||
|
||||
const displayRoutePath = computed(() => {
|
||||
if (isObjectScope.value && isMenu.value) {
|
||||
return selectedRouteBinding.value?.path || toAbsoluteRoutePath(model.value.path);
|
||||
}
|
||||
|
||||
return joinRoutePaths(currentParentFullPath.value, model.value.path);
|
||||
});
|
||||
|
||||
@@ -354,6 +516,24 @@ const hasCompatibleViewRouteData = computed(() =>
|
||||
);
|
||||
|
||||
const rules = computed(() => {
|
||||
const permissionRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.permission'),
|
||||
trigger: 'blur',
|
||||
validator: (_, value, callback) => {
|
||||
if (!showPermissionField.value) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(value ?? '').trim()) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error($t('page.system.menu.form.permission')));
|
||||
}
|
||||
};
|
||||
|
||||
const pathRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.path'),
|
||||
trigger: 'blur',
|
||||
@@ -373,7 +553,7 @@ const rules = computed(() => {
|
||||
};
|
||||
|
||||
const pageResourceRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.pageResource'),
|
||||
message: isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource'),
|
||||
trigger: 'change',
|
||||
validator: (_, value, callback) => {
|
||||
if (!showPageResourceField.value) {
|
||||
@@ -387,7 +567,11 @@ const rules = computed(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error($t('page.system.menu.form.pageResource')));
|
||||
callback(
|
||||
new Error(
|
||||
isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource')
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -514,7 +698,9 @@ const rules = computed(() => {
|
||||
name: createRequiredRule($t('page.system.menu.form.menuName')),
|
||||
type: createRequiredRule($t('page.system.menu.form.menuType')),
|
||||
parentId: createRequiredRule($t('page.system.menu.form.parentId')),
|
||||
permission: permissionRule,
|
||||
sort: createRequiredRule($t('page.system.menu.form.sort')),
|
||||
status: createRequiredRule($t('page.system.menu.form.menuStatus')),
|
||||
path: pathRule,
|
||||
pageResourcePath: pageResourceRule,
|
||||
component: componentRule,
|
||||
@@ -528,10 +714,24 @@ const rules = computed(() => {
|
||||
|
||||
const parentTreeOptions = computed<ParentTreeOption[]>(() => {
|
||||
const menuTree = buildMenuTree(props.allMenus);
|
||||
const descendantIds = currentMenuId.value ? collectDescendantIds(menuTree, currentMenuId.value) : [];
|
||||
const disabledIds = new Set<number>([currentMenuId.value, ...descendantIds].filter(Boolean));
|
||||
const descendantIds = currentMenuId.value !== '0' ? collectDescendantIds(menuTree, currentMenuId.value) : [];
|
||||
const disabledIds = new Set<string>([currentMenuId.value, ...descendantIds].filter(id => id !== '0'));
|
||||
|
||||
const availableMenus = props.allMenus.filter(item => item.type !== 3);
|
||||
const availableMenus = props.allMenus.filter(item => {
|
||||
if (item.type === 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isObjectScope.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMenu.value) {
|
||||
return item.type === 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const availableMenuTree = buildMenuTree(availableMenus);
|
||||
|
||||
function mapTreeOptions(nodes: Api.SystemManage.Menu[]): ParentTreeOption[] {
|
||||
@@ -545,7 +745,7 @@ const parentTreeOptions = computed<ParentTreeOption[]>(() => {
|
||||
|
||||
return [
|
||||
{
|
||||
value: 0,
|
||||
value: '0',
|
||||
label: $t('page.system.menu.topLevel'),
|
||||
children: mapTreeOptions(availableMenuTree)
|
||||
}
|
||||
@@ -572,6 +772,19 @@ function clearFormValidation() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function clearRouteFields() {
|
||||
model.value.path = '';
|
||||
model.value.icon = '';
|
||||
model.value.component = '';
|
||||
model.value.componentName = '';
|
||||
model.value.routeKind = null;
|
||||
model.value.routePropsJson = '';
|
||||
model.value.pageResourcePath = '';
|
||||
model.value.iframeUrl = '';
|
||||
model.value.externalUrl = '';
|
||||
model.value.redirectTarget = '';
|
||||
}
|
||||
|
||||
function applyMenuTypePreset(type: Api.SystemManage.MenuType) {
|
||||
if (type === 1) {
|
||||
model.value.permission = '';
|
||||
@@ -579,28 +792,23 @@ function applyMenuTypePreset(type: Api.SystemManage.MenuType) {
|
||||
model.value.keepAlive = false;
|
||||
}
|
||||
|
||||
if (type === 2 && (!model.value.routeKind || model.value.routeKind === 'dir')) {
|
||||
if (type === 2 && (isObjectScope.value || !model.value.routeKind || model.value.routeKind === 'dir')) {
|
||||
model.value.permission = '';
|
||||
model.value.routeKind = 'view';
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
model.value.path = '';
|
||||
model.value.icon = '';
|
||||
model.value.component = '';
|
||||
model.value.componentName = '';
|
||||
model.value.routeKind = null;
|
||||
model.value.routePropsJson = '';
|
||||
model.value.pageResourcePath = '';
|
||||
model.value.iframeUrl = '';
|
||||
model.value.externalUrl = '';
|
||||
model.value.redirectTarget = '';
|
||||
clearRouteFields();
|
||||
model.value.keepAlive = false;
|
||||
model.value.alwaysShow = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultRouteKind(type: Api.SystemManage.MenuType) {
|
||||
if (isObjectScope.value && type === 2) {
|
||||
return 'view';
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
return 'dir';
|
||||
}
|
||||
@@ -621,22 +829,23 @@ function syncDirectoryRouteFields() {
|
||||
}
|
||||
|
||||
function syncViewRouteFields() {
|
||||
const pageResource = selectedPageResource.value;
|
||||
const routeBinding = selectedRouteBinding.value;
|
||||
|
||||
if (!pageResource) {
|
||||
if (!routeBinding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRelativePath =
|
||||
getRelativeRoutePath(pageResource.path, currentParentFullPath.value) ||
|
||||
normalizeRoutePart(model.value.path) ||
|
||||
normalizeRoutePart(pageResource.path).split('/').filter(Boolean).at(-1) ||
|
||||
'';
|
||||
const nextPath = isObjectScope.value
|
||||
? toAbsoluteRoutePath(routeBinding.path)
|
||||
: getRelativeRoutePath(routeBinding.path, currentParentFullPath.value) ||
|
||||
normalizeRoutePart(model.value.path) ||
|
||||
normalizeRoutePart(routeBinding.path).split('/').filter(Boolean).at(-1) ||
|
||||
'';
|
||||
|
||||
model.value.path = nextRelativePath;
|
||||
model.value.component = pageResource.component;
|
||||
model.value.componentName = pageResource.name;
|
||||
model.value.routePropsJson = stringifyRouteProps(pageResource.props as Record<string, unknown> | null);
|
||||
model.value.path = nextPath;
|
||||
model.value.component = routeBinding.component;
|
||||
model.value.componentName = routeBinding.name;
|
||||
model.value.routePropsJson = stringifyRouteProps(routeBinding.props);
|
||||
}
|
||||
|
||||
function syncIframeRouteFields() {
|
||||
@@ -724,8 +933,8 @@ function syncCurrentRouteFields() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyPageResourceMeta(pageResource: PageResourceItem) {
|
||||
model.value.keepAlive = Boolean(pageResource.keepAlive);
|
||||
function applyRouteBindingMeta(routeBinding: RouteBindingItem) {
|
||||
model.value.keepAlive = Boolean(routeBinding.keepAlive);
|
||||
}
|
||||
|
||||
function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
@@ -734,6 +943,8 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
return {
|
||||
name: data.name,
|
||||
permission: data.permission ?? '',
|
||||
scopeType: data.scopeType ?? props.scopeType,
|
||||
objectType: data.objectType || (props.scopeType === 'object' ? props.objectType : undefined),
|
||||
type: data.type,
|
||||
sort: data.sort,
|
||||
parentId: data.parentId,
|
||||
@@ -747,7 +958,7 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
visible: data.visible ?? true,
|
||||
keepAlive: data.keepAlive ?? false,
|
||||
alwaysShow: data.alwaysShow ?? false,
|
||||
pageResourcePath: data.routeKind === 'view' ? resolvePageResourcePath(data) : '',
|
||||
pageResourcePath: data.routeKind === 'view' ? resolveRouteBindingPath(data) : '',
|
||||
iframeUrl: data.routeKind === 'iframe' ? getRoutePropText(routeProps, 'url') : '',
|
||||
externalUrl: data.routeKind === 'external' ? getRoutePropText(routeProps, 'url') : '',
|
||||
redirectTarget: data.routeKind === 'redirect' ? getRoutePropText(routeProps, 'redirect') : ''
|
||||
@@ -755,28 +966,41 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
}
|
||||
|
||||
function getSubmitData(): Api.SystemManage.SaveMenuParams {
|
||||
const scopeData = getCurrentScopeParams();
|
||||
let submitPath: string | null = null;
|
||||
|
||||
if (showRouteFields.value) {
|
||||
submitPath =
|
||||
isObjectScope.value && isMenu.value
|
||||
? getNullableText(toAbsoluteRoutePath(model.value.path))
|
||||
: getNullableText(model.value.path);
|
||||
}
|
||||
|
||||
return {
|
||||
...scopeData,
|
||||
name: model.value.name.trim(),
|
||||
type: model.value.type,
|
||||
sort: model.value.sort,
|
||||
parentId: model.value.parentId,
|
||||
status: model.value.status,
|
||||
permission: isButton.value ? getNullableText(model.value.permission) : null,
|
||||
path: showRouteFields.value ? getNullableText(model.value.path) : null,
|
||||
path: submitPath,
|
||||
icon: showIconField.value ? getNullableText(model.value.icon) : null,
|
||||
component: showRouteFields.value ? getNullableText(model.value.component) : null,
|
||||
componentName: showRouteFields.value ? getNullableText(model.value.componentName) : null,
|
||||
routeKind: showRouteFields.value ? (model.value.routeKind ?? null) : null,
|
||||
routePropsJson: showRouteFields.value ? getNullableText(model.value.routePropsJson) : null,
|
||||
visible: isButton.value ? false : Boolean(model.value.visible),
|
||||
keepAlive: canKeepAlive.value ? Boolean(model.value.keepAlive) : false,
|
||||
keepAlive: showRouteFields.value ? Boolean(model.value.keepAlive) : false,
|
||||
alwaysShow: false
|
||||
};
|
||||
}
|
||||
|
||||
async function submitMenu(data: Api.SystemManage.SaveMenuParams) {
|
||||
if (isEdit.value && props.rowData) {
|
||||
return fetchUpdateMenu({ id: props.rowData.id, ...data });
|
||||
const { scopeType: _scopeType, objectType: _objectType, ...updateData } = data;
|
||||
|
||||
return fetchUpdateMenu({ id: props.rowData.id, ...updateData });
|
||||
}
|
||||
|
||||
return fetchCreateMenu(data);
|
||||
@@ -788,11 +1012,16 @@ async function initModel() {
|
||||
|
||||
if (isAddChild.value && props.rowData) {
|
||||
model.value.parentId = props.rowData.id;
|
||||
|
||||
if (isObjectScope.value && props.rowData.type === 2) {
|
||||
model.value.type = 3;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEdit.value || !props.rowData) {
|
||||
applyMenuTypePreset(model.value.type);
|
||||
syncCurrentRouteFields();
|
||||
|
||||
await nextTick();
|
||||
clearFormValidation();
|
||||
initializingModel.value = false;
|
||||
@@ -807,6 +1036,7 @@ async function initModel() {
|
||||
|
||||
if (!error) {
|
||||
model.value = mapMenuDetailToModel(data);
|
||||
applyMenuTypePreset(model.value.type);
|
||||
syncCurrentRouteFields();
|
||||
}
|
||||
|
||||
@@ -860,7 +1090,6 @@ watch(
|
||||
() => model.value.parentId,
|
||||
async () => {
|
||||
syncCurrentRouteFields();
|
||||
|
||||
await nextTick();
|
||||
clearFormValidation();
|
||||
}
|
||||
@@ -882,14 +1111,14 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
const pageResource = pageResourceMap.get(value);
|
||||
const routeBinding = routeBindingMap.value.get(value);
|
||||
|
||||
if (!pageResource) {
|
||||
if (!routeBinding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initializingModel.value) {
|
||||
applyPageResourceMeta(pageResource);
|
||||
applyRouteBindingMeta(routeBinding);
|
||||
}
|
||||
|
||||
syncViewRouteFields();
|
||||
@@ -934,7 +1163,7 @@ watch(visible, value => {
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="780px"
|
||||
:width="dialogWidth"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
@@ -945,7 +1174,12 @@ watch(visible, value => {
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuType')" prop="type">
|
||||
<ElRadioGroup v-model="model.type" class="business-form-radio-group" :disabled="isEdit">
|
||||
<ElRadio v-for="{ label, value } in menuTypeOptions" :key="value" :value="value">
|
||||
<ElRadio
|
||||
v-for="{ label, value, disabled } in menuTypeRadioOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
@@ -987,12 +1221,21 @@ watch(visible, value => {
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in commonStatusOptions" :key="value" :value="value">
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="showRouteFields" :title="$t('page.system.menu.sections.route')">
|
||||
<BusinessFormSection v-if="showRouteSection" :title="$t('page.system.menu.sections.route')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElCol v-if="showRouteKindField" :span="12">
|
||||
<ElFormItem prop="routeKind">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
@@ -1048,9 +1291,9 @@ watch(visible, value => {
|
||||
<ElFormItem prop="pageResourcePath">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<span>{{ $t('page.system.menu.pageResource') }}</span>
|
||||
<span>{{ routeBindingFieldLabel }}</span>
|
||||
<ElTooltip
|
||||
:content="$t('page.system.menu.tips.pageResource')"
|
||||
:content="routeBindingFieldTip"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
@@ -1060,12 +1303,8 @@ watch(visible, value => {
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
<ElSelect
|
||||
v-model="model.pageResourcePath"
|
||||
filterable
|
||||
:placeholder="$t('page.system.menu.form.pageResource')"
|
||||
>
|
||||
<ElOption v-for="{ label, value } in pageResourceOptions" :key="value" :label="label" :value="value" />
|
||||
<ElSelect v-model="model.pageResourcePath" filterable :placeholder="routeBindingFieldPlaceholder">
|
||||
<ElOption v-for="{ label, value } in routeBindingOptions" :key="value" :label="label" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuSearch' });
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
@@ -23,12 +32,26 @@ function search() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" :action-col-md="8" @reset="reset" @search="search">
|
||||
<TableSearchPanel
|
||||
:model="model"
|
||||
:disabled="props.disabled"
|
||||
:action-col-lg="8"
|
||||
:action-col-md="24"
|
||||
@reset="reset"
|
||||
@search="search"
|
||||
>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuName')" prop="name">
|
||||
<ElInput v-model="model.name" clearable :placeholder="$t('page.system.menu.form.menuName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuStatus')" prop="status">
|
||||
<ElSelect v-model="model.status" clearable class="w-full" :placeholder="$t('page.system.menu.form.menuStatus')">
|
||||
<ElOption v-for="{ label, value } in commonStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { commonStatusRecord } from '@/constants/business';
|
||||
import { commonStatusRecord, objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { fetchDeleteRole, fetchGetMenuSimpleList, fetchGetRolePage } from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import RoleContextPanel from './modules/role-context-panel.vue';
|
||||
import RoleOperateDialog from './modules/role-operate-dialog.vue';
|
||||
import RoleResourcePanel from './modules/role-resource-panel.vue';
|
||||
import RoleSearch from './modules/role-search.vue';
|
||||
@@ -61,15 +62,36 @@ function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const pendingSelectedRoleId = ref<number | null>(null);
|
||||
const selectedRoleId = ref<string | null>(null);
|
||||
const pendingSelectedRoleId = ref<string | null>(null);
|
||||
const scopeType = ref<Api.SystemManage.ScopeType>('global');
|
||||
const objectType = ref<Api.SystemManage.ObjectType>('product');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (scopeType.value === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: objectType.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetRolePage(searchParams),
|
||||
api: () =>
|
||||
fetchGetRolePage({
|
||||
...searchParams,
|
||||
...getCurrentScopeParams()
|
||||
}),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo, searchParams.pageSize),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
@@ -134,8 +156,9 @@ const menuTree = ref<Api.SystemManage.MenuSimple[]>([]);
|
||||
|
||||
async function getMenuTreeData() {
|
||||
menuTreeLoading.value = true;
|
||||
menuTree.value = [];
|
||||
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList();
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList(getCurrentScopeParams());
|
||||
|
||||
menuTreeLoading.value = false;
|
||||
|
||||
@@ -148,6 +171,19 @@ async function getMenuTreeData() {
|
||||
}
|
||||
|
||||
const currentRole = computed(() => data.value.find(item => item.id === selectedRoleId.value) ?? null);
|
||||
const currentRoleTotal = computed(() => mobilePagination.value.total || data.value.length);
|
||||
const currentScopeLabel = computed(() => $t(scopeTypeRecord[scopeType.value]));
|
||||
const currentObjectTypeLabel = computed(() => {
|
||||
return objectType.value ? $t(objectTypeRecord[objectType.value]) : '';
|
||||
});
|
||||
const currentContextTagLabel = computed(() => {
|
||||
return isObjectScope.value && currentObjectTypeLabel.value
|
||||
? `${currentScopeLabel.value} / ${currentObjectTypeLabel.value}`
|
||||
: currentScopeLabel.value;
|
||||
});
|
||||
const currentTableTitle = computed(() => {
|
||||
return $t(isObjectScope.value ? 'page.system.role.objectRoleTitle' : 'page.system.role.globalRoleTitle');
|
||||
});
|
||||
|
||||
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
@@ -204,7 +240,7 @@ function handleSearch() {
|
||||
getDataByPage(1);
|
||||
}
|
||||
|
||||
function selectRole(roleId: number) {
|
||||
function selectRole(roleId: string) {
|
||||
selectedRoleId.value = roleId;
|
||||
}
|
||||
|
||||
@@ -212,7 +248,7 @@ function handleRowClick(row: Api.SystemManage.Role) {
|
||||
selectRole(row.id);
|
||||
}
|
||||
|
||||
function handleSubmitted(roleId: number) {
|
||||
function handleSubmitted(roleId: string) {
|
||||
pendingSelectedRoleId.value = roleId;
|
||||
closeOperateModal();
|
||||
getData();
|
||||
@@ -241,82 +277,133 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(scopeType, value => {
|
||||
if (value === 'object' && !objectType.value) {
|
||||
objectType.value = 'product';
|
||||
}
|
||||
});
|
||||
|
||||
let contextChangeToken = 0;
|
||||
|
||||
watch([scopeType, objectType], async ([nextScope, nextObject], [prevScope, prevObject]) => {
|
||||
if (nextScope === prevScope && nextObject === prevObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextChangeToken += 1;
|
||||
const token = contextChangeToken;
|
||||
await nextTick();
|
||||
|
||||
if (token !== contextChangeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
selectedRoleId.value = null;
|
||||
pendingSelectedRoleId.value = null;
|
||||
editingData.value = null;
|
||||
closeOperateModal();
|
||||
await getMenuTreeData();
|
||||
await getDataByPage(1);
|
||||
});
|
||||
|
||||
getMenuTreeData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>{{ $t('page.system.role.title') }}</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</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']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
@submitted="handleSubmitted"
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<RoleContextPanel
|
||||
v-model:scope-type="scopeType"
|
||||
v-model:object-type="objectType"
|
||||
:total="currentRoleTotal"
|
||||
:loading="loading || menuTreeLoading"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="role-page-content gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="role-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">{{ currentTableTitle }}</p>
|
||||
<ElTag effect="plain" :type="isObjectScope ? 'success' : 'primary'">
|
||||
{{ currentContextTagLabel }}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ currentRoleTotal }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</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']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:scope-type="scopeType"
|
||||
:object-type="objectType"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.role-page-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.role-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
@@ -345,4 +432,18 @@ getMenuTreeData();
|
||||
:deep(.el-row) {
|
||||
margin: 0 0 -15px 0;
|
||||
}
|
||||
|
||||
.role-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.role-card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
249
src/views/system/role/modules/role-context-panel.vue
Normal file
249
src/views/system/role/modules/role-context-panel.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleContextPanel' });
|
||||
|
||||
interface Props {
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
total: 0,
|
||||
loading: false
|
||||
});
|
||||
|
||||
const scopeType = defineModel<Api.SystemManage.ScopeType>('scopeType', {
|
||||
required: true
|
||||
});
|
||||
|
||||
const objectType = defineModel<Api.SystemManage.ObjectType | undefined>('objectType');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
const scopeOptions = computed(() => [
|
||||
{ label: $t(scopeTypeRecord.global), value: 'global' satisfies Api.SystemManage.ScopeType },
|
||||
{ label: $t(scopeTypeRecord.object), value: 'object' satisfies Api.SystemManage.ScopeType }
|
||||
]);
|
||||
|
||||
const objectTypeOptions = computed(() => [
|
||||
{ label: $t(objectTypeRecord.product), value: 'product' satisfies Api.SystemManage.ObjectType },
|
||||
{ label: $t(objectTypeRecord.project), value: 'project' satisfies Api.SystemManage.ObjectType }
|
||||
]);
|
||||
|
||||
const currentContextLabel = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t(scopeTypeRecord.global);
|
||||
}
|
||||
|
||||
if (!objectType.value) {
|
||||
return `${$t(scopeTypeRecord.object)} / --`;
|
||||
}
|
||||
|
||||
return `${$t(scopeTypeRecord.object)} / ${$t(objectTypeRecord[objectType.value])}`;
|
||||
});
|
||||
|
||||
const currentScopeSummary = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t('page.system.role.globalRoleSummary');
|
||||
}
|
||||
|
||||
if (objectType.value === 'product') {
|
||||
return $t('page.system.role.objectRoleSummaryProduct');
|
||||
}
|
||||
|
||||
if (objectType.value === 'project') {
|
||||
return $t('page.system.role.objectRoleSummaryProject');
|
||||
}
|
||||
|
||||
return $t('page.system.role.objectRoleSummary');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="role-context-panel" body-class="role-context-panel__body">
|
||||
<div v-loading="props.loading" class="role-context-panel__layout">
|
||||
<div class="role-context-panel__controls">
|
||||
<div class="role-context-panel__field role-context-panel__field--switch">
|
||||
<ElSegmented v-model="scopeType" :options="scopeOptions" />
|
||||
</div>
|
||||
|
||||
<span v-if="isObjectScope" class="role-context-panel__divider" aria-hidden="true">|</span>
|
||||
|
||||
<div v-if="isObjectScope" class="role-context-panel__field role-context-panel__field--inline">
|
||||
<span class="role-context-panel__field-label role-context-panel__field-label--inline">
|
||||
{{ $t('page.system.menu.objectType') }}
|
||||
</span>
|
||||
<ElSelect v-model="objectType" class="w-full" :placeholder="$t('page.system.menu.objectTypePlaceholder')">
|
||||
<ElOption v-for="item in objectTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="role-context-panel__info">
|
||||
<div class="role-context-panel__info-main">
|
||||
<div class="role-context-panel__info-item">
|
||||
<span class="role-context-panel__info-label">{{ $t('page.system.menu.currentContext') }}</span>
|
||||
<strong class="role-context-panel__info-value">{{ currentContextLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="role-context-panel__info-item">
|
||||
<span class="role-context-panel__info-label">{{ $t('page.system.role.currentRoleCount') }}</span>
|
||||
<strong class="role-context-panel__info-value">{{ props.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="role-context-panel__info-desc">{{ currentScopeSummary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-context-panel {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__body) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.role-context-panel__layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.role-context-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.role-context-panel__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-context-panel__field--switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--switch .el-segmented) {
|
||||
width: auto;
|
||||
padding: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--switch .el-segmented__item) {
|
||||
min-height: 40px;
|
||||
min-width: 96px;
|
||||
padding: 0 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-context-panel__field-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-context-panel__field--inline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__field-label--inline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__divider {
|
||||
color: var(--el-border-color-darker);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--inline .el-select) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-context-panel__info-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-context-panel__info-desc {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.role-context-panel__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.role-context-panel__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.role-context-panel__info {
|
||||
padding-left: 0;
|
||||
padding-top: 14px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.role-context-panel__controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-context-panel__info-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,12 +11,14 @@ defineOptions({ name: 'RoleOperateDialog' });
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.Role | null;
|
||||
scopeType: Api.SystemManage.ScopeType;
|
||||
objectType?: Api.SystemManage.ObjectType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', roleId: number): void;
|
||||
(e: 'submitted', roleId: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -49,12 +51,27 @@ function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
code: '',
|
||||
scopeType: props.scopeType,
|
||||
objectType: props.scopeType === 'object' ? props.objectType : undefined,
|
||||
sort: 0,
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (props.scopeType === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: props.objectType
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: createRequiredRule($t('page.system.role.form.roleName')),
|
||||
code: createRequiredRule($t('page.system.role.form.roleCode')),
|
||||
@@ -85,6 +102,8 @@ async function initModel() {
|
||||
model.value = {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
scopeType: data.scopeType ?? props.scopeType,
|
||||
objectType: data.objectType || (props.scopeType === 'object' ? props.objectType : undefined),
|
||||
sort: data.sort,
|
||||
status: data.status,
|
||||
remark: data.remark ?? ''
|
||||
@@ -102,26 +121,35 @@ async function handleSubmit() {
|
||||
|
||||
const submitData: Api.SystemManage.SaveRoleParams = {
|
||||
...model.value,
|
||||
...getCurrentScopeParams(),
|
||||
name: model.value.name.trim(),
|
||||
code: model.value.code.trim(),
|
||||
remark: model.value.remark?.trim() || null
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateRole({ id: props.rowData.id, ...submitData })
|
||||
: fetchCreateRole(submitData);
|
||||
let roleId = props.rowData?.id ?? '';
|
||||
|
||||
const { error, data } = await request;
|
||||
if (isEdit.value && props.rowData) {
|
||||
const { scopeType: _scopeType, objectType: _objectType, ...updateData } = submitData;
|
||||
const { error } = await fetchUpdateRole({ id: props.rowData.id, ...updateData });
|
||||
|
||||
submitting.value = false;
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error, data } = await fetchCreateRole(submitData);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
roleId = data;
|
||||
}
|
||||
|
||||
const roleId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
@@ -142,7 +170,6 @@ watch(visible, value => {
|
||||
preset="md"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
|
||||
@@ -25,7 +25,7 @@ const treeRef = ref<TreeInstance | null>(null);
|
||||
const permissionLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const filterKeyword = ref('');
|
||||
const checkedKeys = ref<number[]>([]);
|
||||
const checkedKeys = ref<string[]>([]);
|
||||
|
||||
const disabled = computed(() => !props.role || props.role.status === 1);
|
||||
const checkedCount = computed(() => checkedKeys.value.length);
|
||||
@@ -37,7 +37,7 @@ const treeProps = {
|
||||
label: 'name'
|
||||
} as const;
|
||||
|
||||
function applyCheckedKeys(keys: number[]) {
|
||||
function applyCheckedKeys(keys: string[]) {
|
||||
checkedKeys.value = [...keys];
|
||||
treeRef.value?.setCheckedKeys(keys);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ function filterNode(value: string, data: any) {
|
||||
}
|
||||
|
||||
function collectExpandableNodeIds(nodes: Api.SystemManage.MenuSimple[]) {
|
||||
const ids: number[] = [];
|
||||
const ids: string[] = [];
|
||||
|
||||
const walk = (items: Api.SystemManage.MenuSimple[]) => {
|
||||
items.forEach(item => {
|
||||
@@ -112,7 +112,7 @@ async function loadRoleMenus() {
|
||||
}
|
||||
|
||||
function handleCheck() {
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -120,7 +120,7 @@ async function handleSave() {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
type TreeNodeId = string | number;
|
||||
|
||||
type TreeNode = {
|
||||
id: number;
|
||||
parentId: number;
|
||||
id: TreeNodeId;
|
||||
parentId: TreeNodeId;
|
||||
sort?: number | null;
|
||||
children?: TreeNode[] | null;
|
||||
};
|
||||
|
||||
export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
const nodeMap = new Map<number, T>();
|
||||
const nodeMap = new Map<TreeNodeId, T>();
|
||||
const roots: T[] = [];
|
||||
|
||||
list.forEach(item => {
|
||||
@@ -17,7 +19,7 @@ export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
});
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (node.parentId === 0) {
|
||||
if (isRootParentId(node.parentId)) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
@@ -35,17 +37,17 @@ export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
return sortMenuTree(roots);
|
||||
}
|
||||
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number) {
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: T['id']) {
|
||||
const target = findTreeNode(nodes, targetId);
|
||||
|
||||
if (!target?.children?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
const ids: T['id'][] = [];
|
||||
|
||||
walkTree(target.children, item => {
|
||||
ids.push(item.id);
|
||||
ids.push(item.id as T['id']);
|
||||
});
|
||||
|
||||
return ids;
|
||||
@@ -63,7 +65,7 @@ function sortMenuTree<T extends TreeNode>(nodes: T[]) {
|
||||
return sortedNodes;
|
||||
}
|
||||
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number): T | null {
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: T['id']): T | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
return node;
|
||||
@@ -81,6 +83,10 @@ function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], t
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRootParentId(parentId: TreeNodeId) {
|
||||
return parentId === 0 || parentId === '0';
|
||||
}
|
||||
|
||||
function walkTree<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], callback: (node: T) => void) {
|
||||
for (const node of nodes) {
|
||||
callback(node);
|
||||
|
||||
@@ -54,10 +54,6 @@ const { fromUserIndex = false, deptId = 100, orgType = 'company' } = defineProps
|
||||
*
|
||||
* @param data 节点数据
|
||||
*/
|
||||
function isRootNode(data: Api.SystemManage.UserManagementRelationTreeRespVO): boolean {
|
||||
return treeData.value.some(node => node.userId === data.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断母节点的编辑按钮是否应该隐藏
|
||||
*
|
||||
@@ -89,11 +85,15 @@ const userList = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const relationTreeRef = ref<InstanceType<typeof ElTree>>();
|
||||
|
||||
// 已选中的节点 ID 列表
|
||||
const checkedNodeKeys = ref<number[]>([]);
|
||||
const checkedNodeKeys = ref<string[]>([]);
|
||||
|
||||
// 树形数据
|
||||
const treeData = ref<Api.SystemManage.UserManagementRelationTreeRespVO[]>([]);
|
||||
|
||||
function isRootNode(data: Api.SystemManage.UserManagementRelationTreeRespVO): boolean {
|
||||
return treeData.value.some(node => node.userId === data.userId);
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -225,7 +225,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
operateType.value = 'add';
|
||||
// 如果是从某一行的新增按钮触发,则默认管理者为当前节点用户
|
||||
// 否则默认管理者为当前登录用户(在对话框组件中处理)
|
||||
editingData.value = item ? {
|
||||
editingData.value = item
|
||||
? {
|
||||
id: null,
|
||||
managerUserId: item.userId,
|
||||
subordinateUserId: null,
|
||||
@@ -233,7 +234,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
effectiveUntil: null,
|
||||
remark: null,
|
||||
createTime: Date.now()
|
||||
} : null;
|
||||
}
|
||||
: null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
@@ -245,7 +247,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
function openEdit(item: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
operateType.value = 'edit';
|
||||
// 构建树节点数据为编辑所需格式
|
||||
editingData.value = item.id ? {
|
||||
editingData.value = item.id
|
||||
? {
|
||||
id: item.id,
|
||||
managerUserId: item.managerUserId,
|
||||
subordinateUserId: item.userId,
|
||||
@@ -253,7 +256,8 @@ function openEdit(item: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
effectiveUntil: null,
|
||||
remark: null,
|
||||
createTime: Date.now()
|
||||
} : null;
|
||||
}
|
||||
: null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
@@ -300,7 +304,9 @@ async function handleBatchDelete() {
|
||||
* @param checkedInfo 包含 checkedKeys 和 halfCheckedKeys 的对象
|
||||
*/
|
||||
function handleNodeCheck(checkedData: any, checkedInfo: any) {
|
||||
checkedNodeKeys.value = checkedInfo.checkedNodes.map((node: any) => node.id);
|
||||
checkedNodeKeys.value = checkedInfo.checkedNodes
|
||||
.map((node: any) => node.id)
|
||||
.filter((id: string | null): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,7 +314,7 @@ function handleNodeCheck(checkedData: any, checkedInfo: any) {
|
||||
*
|
||||
* @param relationId 提交后的关系 ID
|
||||
*/
|
||||
function handleSubmitted(relationId: number) {
|
||||
function handleSubmitted(_relationId: string) {
|
||||
closeOperateModal();
|
||||
reloadTreeData();
|
||||
}
|
||||
@@ -411,14 +417,20 @@ onMounted(async () => {
|
||||
<span>{{ node.label }}</span>
|
||||
<!-- <ElTag v-if="data.managerNickname" size="small" type="info">上级:{{ data.managerNickname }}</ElTag>-->
|
||||
</span>
|
||||
<div class="flex items-center" style="min-width: 200px;">
|
||||
<div class="flex items-center" style="min-width: 200px">
|
||||
<ElButton link type="primary" size="default" @click.stop="openAdd(data)">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
<ElButton v-if="!(isRootNode(data) && shouldHideRootEdit)" link type="primary" size="small" @click.stop="openEdit(data)">
|
||||
<ElButton
|
||||
v-if="!(isRootNode(data) && shouldHideRootEdit)"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="openEdit(data)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-edit class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@ const props = defineProps<Props>();
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
/** 提交事件:返回提交后的关系 ID */
|
||||
submitted: [relationId: number];
|
||||
submitted: [relationId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
@@ -126,11 +126,38 @@ function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function resetValidateState() {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function resolveDefaultManagerUserId() {
|
||||
if (props.rowData?.managerUserId) {
|
||||
return props.rowData.managerUserId;
|
||||
}
|
||||
|
||||
const currentUserId = authStore.userInfo.userId;
|
||||
const currentUserName = authStore.userInfo.userName;
|
||||
|
||||
if (!currentUserId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedById = props.userList.find(user => user.id === currentUserId);
|
||||
|
||||
if (matchedById) {
|
||||
return matchedById.id;
|
||||
}
|
||||
|
||||
return currentUserName ? props.userList.find(user => user.nickname === currentUserName)?.id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化表单模型
|
||||
*
|
||||
* 编辑模式下加载详情数据,新增模式下设置默认值
|
||||
*/
|
||||
// eslint-disable-next-line complexity
|
||||
async function initModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
@@ -138,18 +165,18 @@ async function initModel() {
|
||||
// 新增模式:设置管理者用户
|
||||
// 优先使用 rowData 中传入的管理者用户 ID(如从树形节点新增)
|
||||
// 否则使用当前登录用户
|
||||
let managerUserIdToSet: number | undefined;
|
||||
let managerUserIdToSet = resolveDefaultManagerUserId();
|
||||
|
||||
if (props.rowData && props.rowData.managerUserId) {
|
||||
// 从树形节点点击新增,管理者为当前节点用户
|
||||
managerUserIdToSet = props.rowData.managerUserId;
|
||||
} else if (authStore.userInfo.userId) {
|
||||
// 头部新增,管理者为当前登录用户
|
||||
const currentUserId = Number(authStore.userInfo.userId);
|
||||
const currentUserId = authStore.userInfo.userId;
|
||||
const currentUserName = authStore.userInfo.userName;
|
||||
|
||||
// 先尝试通过 ID 匹配
|
||||
let currentUser = props.userList.find(user => Number(user.id) === currentUserId);
|
||||
let currentUser = props.userList.find(user => user.id === currentUserId);
|
||||
|
||||
// 如果 ID 匹配失败,尝试通过用户名匹配
|
||||
if (!currentUser && currentUserName) {
|
||||
@@ -165,26 +192,31 @@ async function initModel() {
|
||||
model.value.managerUserId = managerUserIdToSet;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
// 编辑模式:加载详情数据
|
||||
if (!props.rowData) {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
const relationId = props.rowData.id;
|
||||
|
||||
if (!relationId) {
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
|
||||
try {
|
||||
const { error, data } = await fetchGetUserManagementRelation(props.rowData.id);
|
||||
const { error, data } = await fetchGetUserManagementRelation(relationId);
|
||||
|
||||
if (data !== null && !error) {
|
||||
model.value = {
|
||||
id: data.id,
|
||||
id: data.id ?? undefined,
|
||||
managerUserId: data.managerUserId,
|
||||
subordinateUserId: data.subordinateUserId,
|
||||
effectiveFrom: data.effectiveFrom ? new Date(data.effectiveFrom).getTime() : null,
|
||||
@@ -196,8 +228,7 @@ async function initModel() {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,23 +246,31 @@ async function handleSubmit() {
|
||||
...model.value
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? await fetchUpdateUserManagementRelation({ id: props.rowData.id, ...submitData })
|
||||
: await fetchCreateUserManagementRelation(submitData);
|
||||
const editRelationId = props.rowData?.id ?? null;
|
||||
|
||||
const { error, data } = request;
|
||||
if (isEdit.value && editRelationId) {
|
||||
const { error } = await fetchUpdateUserManagementRelation({ ...submitData, id: editRelationId });
|
||||
|
||||
if (error) {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('淇敼鎴愬姛');
|
||||
closeModal();
|
||||
emit('submitted', editRelationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const relationId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
const { error, data } = await fetchCreateUserManagementRelation(submitData);
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(isEdit.value ? '修改成功' : '新增成功');
|
||||
|
||||
closeModal();
|
||||
emit('submitted', relationId);
|
||||
emit('submitted', data);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="tsx">
|
||||
import {computed, nextTick, onMounted, reactive, ref, watch} from 'vue';
|
||||
import type {TableInstance} from 'element-plus';
|
||||
import {ElButton, ElPopconfirm, ElSwitch, ElTag} from 'element-plus';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElButton, ElPopconfirm, ElSwitch, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import type {FlatResponseData} from '@sa/axios';
|
||||
import {userGenderRecord} from '@/constants/business';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { userGenderRecord } from '@/constants/business';
|
||||
import {
|
||||
fetchBatchDeleteUser,
|
||||
fetchDeleteDept,
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
fetchUpdateUser,
|
||||
fetchUpdateUserStatus
|
||||
} from '@/service/api';
|
||||
import {useUIPaginatedTable} from '@/hooks/common/table';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {$t} from '@/locales';
|
||||
import {buildMenuTree} from '@/views/system/shared/menu-tree';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import UserManagementRelation from '@/views/system/user-management-relation/index.vue';
|
||||
import UserOperateDialog from './modules/user-operate-dialog.vue';
|
||||
import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue';
|
||||
@@ -32,7 +32,7 @@ import UserResignedDialog from './modules/user-resigned-dialog.vue';
|
||||
import UserResetPasswordDialog from './modules/user-reset-password-dialog.vue';
|
||||
import UserSearch from './modules/user-search.vue';
|
||||
|
||||
defineOptions({name: 'UserManage'});
|
||||
defineOptions({ name: 'UserManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.UserSearchParams {
|
||||
return {
|
||||
@@ -158,7 +158,7 @@ const deptTree = computed(() => buildMenuTree(deptList.value));
|
||||
const currentDept = computed(() => deptList.value.find(item => item.id === currentDeptId.value) ?? null);
|
||||
const deptCount = computed(() => deptList.value.length);
|
||||
|
||||
const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} = useUIPaginatedTable<
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
Api.SystemManage.User
|
||||
>({
|
||||
@@ -182,9 +182,9 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{prop: 'selection', type: 'selection', width: 48},
|
||||
{prop: 'index', type: 'index', label: $t('common.index'), width: 64},
|
||||
{prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true},
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'nickname',
|
||||
label: $t('page.system.user.nickName'),
|
||||
@@ -274,9 +274,9 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
formatter: row => {
|
||||
const state = getUserResignedState(row);
|
||||
const stateMap: Record<UserResignedState, { type: UI.ThemeColor; label: App.I18n.I18nKey }> = {
|
||||
active: {type: 'success', label: 'page.system.user.resignedStateEnum.active'},
|
||||
pending: {type: 'warning', label: 'page.system.user.resignedStateEnum.pending'},
|
||||
resigned: {type: 'info', label: 'page.system.user.resignedStateEnum.resigned'}
|
||||
active: { type: 'success', label: 'page.system.user.resignedStateEnum.active' },
|
||||
pending: { type: 'warning', label: 'page.system.user.resignedStateEnum.pending' },
|
||||
resigned: { type: 'info', label: 'page.system.user.resignedStateEnum.resigned' }
|
||||
};
|
||||
|
||||
return <ElTag type={stateMap[state].type}>{$t(stateMap[state].label)}</ElTag>;
|
||||
@@ -337,7 +337,7 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
async function loadDeptTree() {
|
||||
deptLoading.value = true;
|
||||
|
||||
const {error, data: deptItems} = await fetchGetDeptList({
|
||||
const { error, data: deptItems } = await fetchGetDeptList({
|
||||
status: 0
|
||||
});
|
||||
|
||||
@@ -452,7 +452,7 @@ function openOrgLeader(row: Api.SystemManage.Dept) {
|
||||
}
|
||||
|
||||
async function handleDeleteDeptAction(row: Api.SystemManage.Dept) {
|
||||
const {error} = await fetchDeleteDept(row.id);
|
||||
const { error } = await fetchDeleteDept(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -477,7 +477,7 @@ async function handleDeleteAction(row: Api.SystemManage.User) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error} = await fetchDeleteUser(row.id);
|
||||
const { error } = await fetchDeleteUser(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -496,7 +496,7 @@ async function updateUserResignedAt(userId: number, value: number | null) {
|
||||
|
||||
const user = detailResult.data;
|
||||
|
||||
const {error} = await fetchUpdateUser({
|
||||
const { error } = await fetchUpdateUser({
|
||||
id: userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? null,
|
||||
@@ -548,7 +548,7 @@ async function handleBatchDelete() {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error} = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
const { error } = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -561,7 +561,7 @@ async function handleBatchDelete() {
|
||||
async function handleToggleStatus(row: Api.SystemManage.User, enabled: boolean) {
|
||||
statusLoadingIds.value = [...statusLoadingIds.value, row.id];
|
||||
|
||||
const {error} = await fetchUpdateUserStatus({
|
||||
const { error } = await fetchUpdateUserStatus({
|
||||
id: row.id,
|
||||
status: enabled ? 0 : 1
|
||||
});
|
||||
@@ -671,13 +671,13 @@ onMounted(async () => {
|
||||
<template #default>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon"/>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="userManagementRelationVisible = true">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon"/>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
管理链路
|
||||
</ElButton>
|
||||
@@ -685,7 +685,7 @@ onMounted(async () => {
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="userCheckedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon"/>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
@@ -707,7 +707,7 @@ onMounted(async () => {
|
||||
:data="data"
|
||||
@selection-change="handleUserSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col"/>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
@@ -722,7 +722,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')"/>
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
@@ -763,7 +763,7 @@ onMounted(async () => {
|
||||
@submitted="handleDeptSubmitted"
|
||||
/>
|
||||
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData"/>
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData" />
|
||||
|
||||
<BusinessFormDialog
|
||||
v-model="userManagementRelationVisible"
|
||||
@@ -772,7 +772,7 @@ onMounted(async () => {
|
||||
:show-footer="false"
|
||||
max-body-height="70vh"
|
||||
>
|
||||
<UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType"/>
|
||||
<UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType" />
|
||||
</BusinessFormDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +56,7 @@ const title = computed(() => {
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
type Model = Api.SystemManage.SaveUserParams & {
|
||||
roleIds: number[];
|
||||
roleIds: string[];
|
||||
};
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, watch} from 'vue';
|
||||
import {commonStatusOptions} from '@/constants/business';
|
||||
import {fetchCreateDept, fetchUpdateDept} from '@/service/api';
|
||||
import {useForm, useFormRules} from '@/hooks/common/form';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDept, fetchUpdateDept } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {$t} from '@/locales';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({name: 'UserOrgOperateDialog'});
|
||||
defineOptions({ name: 'UserOrgOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
@@ -28,8 +28,8 @@ const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const {formRef, validate} = useForm();
|
||||
const {createRequiredRule} = useFormRules();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
@@ -44,10 +44,10 @@ const title = computed(() => {
|
||||
});
|
||||
|
||||
const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [
|
||||
{value: 'company', label: 'page.system.user.orgType.company'},
|
||||
{value: 'dept', label: 'page.system.user.orgType.dept'},
|
||||
{value: 'direction', label: 'page.system.user.orgType.direction'},
|
||||
{value: 'team', label: 'page.system.user.orgType.team'}
|
||||
{ value: 'company', label: 'page.system.user.orgType.company' },
|
||||
{ value: 'dept', label: 'page.system.user.orgType.dept' },
|
||||
{ value: 'direction', label: 'page.system.user.orgType.direction' },
|
||||
{ value: 'team', label: 'page.system.user.orgType.team' }
|
||||
];
|
||||
|
||||
type Model = Api.SystemManage.SaveDeptParams;
|
||||
@@ -149,7 +149,7 @@ async function handleSubmit() {
|
||||
} as Api.SystemManage.SaveDeptParams;
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
const {error} = await fetchUpdateDept({
|
||||
const { error } = await fetchUpdateDept({
|
||||
id: props.rowData.id,
|
||||
...payload
|
||||
});
|
||||
@@ -166,7 +166,7 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error, data} = await fetchCreateDept(payload);
|
||||
const { error, data } = await fetchCreateDept(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
@@ -203,7 +203,7 @@ watch(visible, async value => {
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.orgName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')"/>
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
@@ -222,7 +222,7 @@ watch(visible, async value => {
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgTypeLabel')" prop="orgType">
|
||||
<ElSelect v-model="model.orgType" :placeholder="$t('page.system.user.form.orgTypeLabel')">
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value"/>
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
@@ -236,7 +236,7 @@ watch(visible, async value => {
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput v-else v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')"/>
|
||||
<ElInput v-else v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { SYSTEM_USER_COMPANY_DICT_CODE } from '@/constants/dict';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
@@ -8,7 +10,6 @@ defineOptions({ name: 'UserSearch' });
|
||||
|
||||
interface Props {
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
companyOptions: Api.Dict.DictData[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -75,15 +76,13 @@ const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem label="所属公司" prop="company">
|
||||
<ElSelect
|
||||
<DictSelect
|
||||
v-model="model.company"
|
||||
clearable
|
||||
:dict-code="SYSTEM_USER_COMPANY_DICT_CODE"
|
||||
filterable
|
||||
:disabled="disabled"
|
||||
placeholder="请选择所属公司"
|
||||
>
|
||||
<ElOption v-for="item in companyOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
|
||||
Reference in New Issue
Block a user