feat(projects): 工作台小组件设计

This commit is contained in:
2026-05-28 08:20:01 +08:00
parent 3988eaf910
commit 4ed4b537ad
54 changed files with 4726 additions and 2720 deletions

View File

@@ -21,6 +21,7 @@ const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+
<template>
<div class="business-rich-text-view">
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-else class="business-rich-text-view__content" v-html="safeHtml" />
</div>
</template>

View File

@@ -0,0 +1,920 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { usePickerSelection } from './business-user-picker/composables/use-picker-selection';
import { useDeptSource } from './business-user-picker/composables/use-dept-source';
import { useChainSource } from './business-user-picker/composables/use-chain-source';
import UserPickerTrigger from './business-user-picker/components/user-picker-trigger.vue';
import IconEpOfficeBuilding from '~icons/ep/office-building';
import IconEpUser from '~icons/ep/user';
defineOptions({ name: 'BusinessUserPicker' });
type Source = 'dept' | 'chain' | 'all';
interface Props {
userOptions: Api.SystemManage.UserSimple[];
sources?: Source[];
multiple?: boolean;
disabledUserIds?: readonly string[];
excludeUserIds?: readonly string[];
disabledLabel?: string;
placeholder?: string;
title?: string;
dialogWidth?: string;
confirmText?: string;
triggerSize?: 'default' | 'small' | 'large';
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
sources: () => ['dept', 'chain', 'all'],
multiple: false,
disabledUserIds: () => [],
excludeUserIds: () => [],
disabledLabel: '',
placeholder: '请选择用户',
title: '选择用户',
dialogWidth: '820px',
confirmText: '',
triggerSize: 'default',
disabled: false
});
interface Emits {
(e: 'change', value: string | string[] | null): void;
(e: 'confirm', payload: { userIds: string[] }): void;
(e: 'cancel'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<string | string[] | null>({ default: null });
const visible = defineModel<boolean>('visible', { default: false });
const source = ref<Source>(props.sources[0] ?? 'all');
const currentNodeId = ref<string | null>(null);
const treeSearch = ref('');
const userSearch = ref('');
const hideAdded = ref(false);
const disabledUserIdSet = computed(() => new Set(props.disabledUserIds.map(String)));
const excludeUserIdSet = computed(() => new Set(props.excludeUserIds.map(String)));
const selection = usePickerSelection(() => ({ multiple: props.multiple }));
const deptSource = useDeptSource(
() => props.userOptions,
() => new Set(selection.selectedIds.value),
() => disabledUserIdSet.value
);
const chainSource = useChainSource(
() => new Set(selection.selectedIds.value),
() => disabledUserIdSet.value
);
const showTabs = computed(() => props.sources.length > 1);
const userByIdMap = computed(() => new Map(props.userOptions.map(u => [String(u.id), u])));
const selectedUsers = computed(() =>
selection.selectedIds.value
.map(id => userByIdMap.value.get(id))
.filter((u): u is Api.SystemManage.UserSimple => Boolean(u))
);
const lockedSelectedIds = computed(() => selection.selectedIds.value.filter(id => disabledUserIdSet.value.has(id)));
const visibleSelectedIds = computed(() => selection.selectedIds.value.slice(0, 4));
const overflowSelectedCount = computed(() => Math.max(0, selection.size.value - 4));
const overflowSelectedIds = computed(() => selection.selectedIds.value.slice(4));
const overflowPopoverVisible = ref(false);
const overflowReferenceEl = ref<HTMLElement | null>(null);
function handleOverflowOutsideClick(e: MouseEvent) {
if (!overflowPopoverVisible.value) return;
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('.user-picker__overflow-popper')) return;
if (target.closest('.el-popper')) return;
if (overflowReferenceEl.value?.contains(target)) return;
overflowPopoverVisible.value = false;
}
onMounted(() => document.addEventListener('mousedown', handleOverflowOutsideClick, true));
onBeforeUnmount(() => document.removeEventListener('mousedown', handleOverflowOutsideClick, true));
function getUserById(uid: string) {
return userByIdMap.value.get(uid);
}
function visibleUserIds(): string[] {
let pool: string[];
if (source.value === 'all' || !currentNodeId.value) {
pool = props.userOptions.map(u => String(u.id));
} else if (source.value === 'dept') {
const node = deptSource.findNode(deptSource.tree.value, currentNodeId.value);
pool = node ? deptSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
} else {
const node = chainSource.findNode(chainSource.tree.value, currentNodeId.value);
pool = node ? chainSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
}
return pool.filter(id => !excludeUserIdSet.value.has(id));
}
const filteredUserIds = computed(() => {
let ids = visibleUserIds();
if (hideAdded.value) ids = ids.filter(id => !disabledUserIdSet.value.has(id));
const kw = userSearch.value.trim().toLowerCase();
if (kw) {
ids = ids.filter(id => {
const u = getUserById(id);
if (!u) return false;
return (
u.nickname.toLowerCase().includes(kw) ||
(u.username ?? '').toLowerCase().includes(kw) ||
(u.deptName ?? '').toLowerCase().includes(kw)
);
});
}
return ids;
});
async function switchSource(next: Source) {
if (source.value === next) return;
source.value = next;
currentNodeId.value = null;
treeSearch.value = '';
if (next === 'dept') await deptSource.ensureLoaded();
else if (next === 'chain') await chainSource.ensureLoaded();
}
function handleDeptNodeClick(data: Api.SystemManage.DeptSimple) {
currentNodeId.value = deptSource.nodeKey(data);
}
function handleChainNodeClick(data: Api.SystemManage.UserManagementRelationTreeRespVO) {
currentNodeId.value = chainSource.nodeKey(data);
}
function toggleDeptCheck(node: Api.SystemManage.DeptSimple) {
if (!props.multiple) return;
const ids = deptSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
const state = deptSource.getNodeCheckState(node);
if (state === 'all') selection.removeMany(ids);
else selection.addMany(ids);
}
function toggleChainCheck(node: Api.SystemManage.UserManagementRelationTreeRespVO) {
if (!props.multiple) return;
const ids = chainSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
const state = chainSource.getNodeCheckState(node);
if (state === 'all') selection.removeMany(ids);
else selection.addMany(ids);
}
function toggleUser(uid: string) {
if (disabledUserIdSet.value.has(uid)) return;
selection.toggle(uid);
}
function clearAll() {
selection.clear(lockedSelectedIds.value);
}
function clearUserFilter() {
userSearch.value = '';
hideAdded.value = false;
}
const confirmDisabled = computed(() => {
if (!props.multiple) return !selection.selectedIds.value.length;
return selection.size.value === 0;
});
const resolvedConfirmText = computed(() => {
if (props.confirmText) return props.confirmText;
if (!props.multiple) return '确定';
return `确定(${selection.size.value})`;
});
function handleConfirm() {
if (confirmDisabled.value) return;
const value = selection.commit();
model.value = value;
emit('change', value);
emit('confirm', { userIds: selection.selectedIds.value });
visible.value = false;
}
function handleCancel() {
emit('cancel');
visible.value = false;
}
function openDialog() {
visible.value = true;
}
watch(visible, async value => {
if (value) {
treeSearch.value = '';
userSearch.value = '';
hideAdded.value = false;
currentNodeId.value = null;
source.value = props.sources[0] ?? 'all';
selection.reset(model.value);
if (source.value === 'dept') await deptSource.ensureLoaded();
else if (source.value === 'chain') await chainSource.ensureLoaded();
await nextTick();
}
});
</script>
<template>
<div class="business-user-picker">
<slot name="trigger" :open="openDialog" :selected-users="selectedUsers" :disabled="disabled">
<UserPickerTrigger
:selected-users="selectedUsers"
:placeholder="placeholder"
:multiple="multiple"
:disabled="disabled"
:size="triggerSize"
@open="openDialog"
/>
</slot>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="lg"
:width="dialogWidth"
max-body-height="540px"
:confirm-disabled="confirmDisabled"
:confirm-text="resolvedConfirmText"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<div class="user-picker">
<div v-if="showTabs" class="user-picker__tabs">
<button
v-for="tab in sources"
:key="tab"
class="user-picker__tab"
:class="{ 'is-active': source === tab }"
type="button"
@click="switchSource(tab)"
>
{{ tab === 'dept' ? '部门' : tab === 'chain' ? '团队' : '全部用户' }}
</button>
</div>
<div class="user-picker__picker" :class="{ 'is-single': source === 'all' }">
<div v-if="source !== 'all'" class="user-picker__col user-picker__col--tree">
<div class="user-picker__col-head">{{ source === 'dept' ? '部门' : '团队' }}</div>
<div class="user-picker__search">
<ElInput
v-model="treeSearch"
size="small"
clearable
:placeholder="source === 'dept' ? '搜索部门…' : '搜索成员…'"
/>
</div>
<div
v-loading="source === 'dept' ? deptSource.loading.value : chainSource.loading.value"
class="user-picker__col-body"
>
<ElTree
v-if="source === 'dept'"
:data="deptSource.filterByKeyword(treeSearch)"
:props="deptSource.treeProps.value"
node-key="id"
:expand-on-click-node="false"
:default-expand-all="true"
:indent="14"
class="user-picker__tree"
@node-click="handleDeptNodeClick"
>
<template #default="{ data }">
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === String(data.id) }">
<span
v-if="multiple"
class="user-picker__node-check"
:class="{
'is-checked': deptSource.getNodeCheckState(data) === 'all',
'is-partial': deptSource.getNodeCheckState(data) === 'partial'
}"
@click.stop="toggleDeptCheck(data)"
/>
<IconEpOfficeBuilding class="user-picker__node-icon" />
<span class="user-picker__node-label">{{ data.name }}</span>
<span v-if="deptSource.getMetaText(data)" class="user-picker__node-meta">
{{ deptSource.getMetaText(data) }}
</span>
</div>
</template>
</ElTree>
<ElTree
v-else
:data="chainSource.filterByKeyword(treeSearch)"
:props="chainSource.treeProps.value"
node-key="userId"
:expand-on-click-node="false"
:default-expand-all="true"
:indent="14"
class="user-picker__tree"
@node-click="handleChainNodeClick"
>
<template #default="{ data }">
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === chainSource.nodeKey(data) }">
<span
v-if="multiple"
class="user-picker__node-check"
:class="{
'is-checked': chainSource.getNodeCheckState(data) === 'all',
'is-partial': chainSource.getNodeCheckState(data) === 'partial'
}"
@click.stop="toggleChainCheck(data)"
/>
<IconEpUser class="user-picker__node-icon" />
<span class="user-picker__node-label">{{ data.userNickname }}</span>
<span v-if="chainSource.getMetaText(data)" class="user-picker__node-meta">
{{ chainSource.getMetaText(data) }}
</span>
</div>
</template>
</ElTree>
</div>
</div>
<div class="user-picker__col user-picker__col--users">
<div class="user-picker__col-head user-picker__col-head--user">
<span>
候选用户(
<span>{{ filteredUserIds.length }}</span>
)
</span>
<label v-if="multiple" class="user-picker__hide-added">
<ElCheckbox v-model="hideAdded">隐藏已添加</ElCheckbox>
</label>
</div>
<div class="user-picker__search">
<ElInput
v-model="userSearch"
size="small"
clearable
:placeholder="source === 'all' ? '搜索用户名 / 部门…' : '搜索用户名…'"
/>
</div>
<div class="user-picker__col-body">
<div v-if="!filteredUserIds.length" class="user-picker__empty">
该节点下没有匹配用户
<button
v-if="userSearch || hideAdded"
type="button"
class="user-picker__link user-picker__empty-action"
@click="clearUserFilter"
>
清除筛选条件
</button>
</div>
<div
v-for="uid in filteredUserIds"
:key="uid"
class="user-picker__user-row"
:class="{
'is-disabled': disabledUserIdSet.has(uid),
'is-selected': !multiple && selection.has(uid)
}"
@click="toggleUser(uid)"
>
<span v-if="multiple" class="user-picker__node-check" :class="{ 'is-checked': selection.has(uid) }" />
<span class="user-picker__user-avatar">{{ (getUserById(uid)?.nickname ?? '?').slice(0, 1) }}</span>
<div class="user-picker__user-main">
<div class="user-picker__user-name">{{ getUserById(uid)?.nickname }}</div>
</div>
<span v-if="disabledUserIdSet.has(uid) && disabledLabel" class="user-picker__user-tag">
{{ disabledLabel }}
</span>
</div>
</div>
</div>
</div>
<div v-if="multiple" class="user-picker__selected">
<div class="user-picker__selected-head">
<span>
已选
<strong>{{ selection.size.value }}</strong>
</span>
<button
v-if="selection.size.value > lockedSelectedIds.length"
type="button"
class="user-picker__link user-picker__link--danger"
@click="clearAll"
>
清空
</button>
</div>
<div v-if="selection.size.value === 0" class="user-picker__selected-empty">从左侧勾选用户后会出现在这里</div>
<div v-else class="user-picker__chips">
<span v-for="uid in visibleSelectedIds" :key="uid" class="user-picker__chip">
<span class="user-picker__chip-name">
{{ getUserById(uid)?.nickname }}
<ElTooltip v-if="disabledUserIdSet.has(uid) && disabledLabel" :content="disabledLabel" placement="top">
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
</ElTooltip>
</span>
<button
v-if="!disabledUserIdSet.has(uid)"
type="button"
class="user-picker__chip-x"
@click="toggleUser(uid)"
>
×
</button>
</span>
<ElPopover
v-if="overflowSelectedCount > 0"
:visible="overflowPopoverVisible"
placement="top-end"
:width="360"
popper-class="user-picker__overflow-popper"
>
<template #reference>
<button
ref="overflowReferenceEl"
type="button"
class="user-picker__chip-more"
@click="overflowPopoverVisible = !overflowPopoverVisible"
>
+{{ overflowSelectedCount }} 更多
</button>
</template>
<div class="user-picker__overflow-head">
<span>
另外
<strong>{{ overflowSelectedCount }}</strong>
</span>
</div>
<div class="user-picker__overflow-chips">
<span v-for="uid in overflowSelectedIds" :key="uid" class="user-picker__chip">
<span class="user-picker__chip-name">
{{ getUserById(uid)?.nickname }}
<ElTooltip
v-if="disabledUserIdSet.has(uid) && disabledLabel"
:content="disabledLabel"
placement="top"
>
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
</ElTooltip>
</span>
<button
v-if="!disabledUserIdSet.has(uid)"
type="button"
class="user-picker__chip-x"
@click="toggleUser(uid)"
>
×
</button>
</span>
</div>
</ElPopover>
</div>
</div>
</div>
</BusinessFormDialog>
</div>
</template>
<style scoped>
.business-user-picker {
display: block;
width: 100%;
}
/* picker 内容上下贴满,标准 body padding 显得空——仅在含本组件的 dialog 上收紧 */
:deep(.business-form-dialog__body:has(.user-picker)) {
padding-top: 8px !important;
padding-bottom: 8px !important;
}
.user-picker {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-picker__tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--el-border-color);
}
.user-picker__tab {
padding: 6px 14px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12.5px;
color: var(--el-text-color-regular);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.user-picker__tab.is-active {
color: var(--el-color-primary);
border-bottom-color: var(--el-color-primary);
font-weight: 600;
}
.user-picker__picker {
display: grid;
grid-template-columns: 240px 1fr;
gap: 12px;
height: min(280px, 44vh);
min-height: 260px;
}
.user-picker__picker.is-single {
grid-template-columns: 1fr;
}
.user-picker__col {
display: flex;
flex-direction: column;
border: 1px solid var(--el-border-color);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.user-picker__col-head {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
background: #fafbfc;
font-size: 12px;
color: var(--el-text-color-regular);
}
.user-picker__col-head--user {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-picker__col-body {
flex: 1;
overflow-y: auto;
}
.user-picker__search {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
}
.user-picker__tree {
padding: 4px;
background: transparent;
}
.user-picker__tree :deep(.el-tree-node__content) {
height: 32px;
padding-right: 8px !important;
border-radius: 4px;
transition: background-color 0.15s ease;
}
.user-picker__tree :deep(.el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
.user-picker__tree :deep(.el-tree-node__expand-icon) {
padding: 4px;
color: var(--el-text-color-placeholder);
font-size: 14px;
}
.user-picker__tree :deep(.el-tree-node__expand-icon.is-leaf) {
color: transparent;
}
.user-picker__node {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
height: 100%;
font-size: 13px;
color: var(--el-text-color-primary);
}
.user-picker__node.is-active {
color: var(--el-color-primary);
font-weight: 500;
}
.user-picker__node-check {
position: relative;
flex-shrink: 0;
width: 14px;
height: 14px;
border: 1px solid var(--el-border-color);
border-radius: 2px;
background: var(--el-bg-color);
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.user-picker__node-check:hover {
border-color: var(--el-color-primary);
}
.user-picker__node-check.is-checked {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.user-picker__node-check.is-checked::after {
content: '';
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 7px;
border: solid #fff;
border-width: 0 1px 1px 0;
transform: rotate(45deg);
}
.user-picker__node-check.is-partial {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.user-picker__node-check.is-partial::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 2px;
margin: -1px 0 0 -4px;
background: #fff;
border-radius: 1px;
}
.user-picker__node-icon {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
transition: color 0.15s ease;
}
.user-picker__node.is-active .user-picker__node-icon {
color: var(--el-color-primary);
}
.user-picker__node-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker__node-meta {
flex-shrink: 0;
padding-left: 6px;
font-size: 12px;
color: var(--el-text-color-placeholder);
font-variant-numeric: tabular-nums;
}
.user-picker__node.is-active .user-picker__node-meta {
color: var(--el-color-primary);
opacity: 0.7;
}
.user-picker__user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px;
height: 36px;
border-bottom: 1px solid var(--el-border-color-lighter);
cursor: pointer;
}
.user-picker__user-row:hover {
background: var(--el-fill-color);
}
.user-picker__user-row.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}
.user-picker__user-row.is-disabled:hover {
background: transparent;
}
.user-picker__user-row.is-selected {
background: var(--el-color-primary-light-9);
}
.user-picker__user-row.is-selected .user-picker__user-name {
color: var(--el-color-primary);
font-weight: 500;
}
.user-picker__user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #c7d2fe, #93c5fd);
color: #fff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.user-picker__user-main {
flex: 1;
min-width: 0;
overflow: hidden;
}
.user-picker__user-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker__user-tag {
flex-shrink: 0;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
background: var(--el-color-warning-light-7);
color: var(--el-color-warning-dark-2);
}
.user-picker__empty {
padding: 40px 0;
text-align: center;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.user-picker__hide-added {
font-size: 11.5px;
}
.user-picker__empty-action {
display: block;
margin: 6px auto 0;
}
.user-picker__selected {
padding: 8px 12px;
background: #f8fafc;
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.user-picker__selected-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 11.5px;
color: var(--el-text-color-regular);
}
.user-picker__selected-head strong {
color: var(--el-color-primary);
font-weight: 700;
font-size: 12.5px;
}
.user-picker__selected-empty {
display: flex;
align-items: center;
min-height: 26px;
color: var(--el-text-color-placeholder);
font-size: 11.5px;
}
.user-picker__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 26px;
}
.user-picker__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 8px;
background: #fff;
border: 1px solid var(--el-border-color-darker);
border-radius: 999px;
font-size: 11.5px;
}
.user-picker__chip-name {
display: inline-flex;
align-items: center;
gap: 2px;
}
.user-picker__chip-lock {
color: var(--el-color-warning-dark-2);
font-size: 11px;
}
.user-picker__chip-x {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--el-fill-color);
color: var(--el-text-color-regular);
border: none;
cursor: pointer;
font-size: 11px;
}
.user-picker__chip-x:hover {
background: var(--el-color-danger);
color: #fff;
}
.user-picker__chip-more {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px dashed var(--el-border-color-darker);
background: transparent;
color: var(--el-color-primary);
font-size: 11.5px;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.user-picker__chip-more:hover {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.user-picker__overflow-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--el-text-color-regular);
}
.user-picker__overflow-head strong {
color: var(--el-color-primary);
font-weight: 700;
}
.user-picker__overflow-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 260px;
overflow-y: auto;
}
.user-picker__link {
background: transparent;
border: none;
cursor: pointer;
font-size: 11.5px;
padding: 0;
}
.user-picker__link--danger {
color: var(--el-color-danger);
}
.user-picker__link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'UserPickerTrigger' });
interface Props {
selectedUsers: Api.SystemManage.UserSimple[];
placeholder: string;
multiple: boolean;
disabled: boolean;
size: 'default' | 'small' | 'large';
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'open'): void }>();
const displayText = computed(() => {
if (!props.selectedUsers.length) return '';
if (!props.multiple) return props.selectedUsers[0]?.nickname ?? '';
const head = props.selectedUsers
.slice(0, 2)
.map(u => u.nickname)
.join('、');
const rest = props.selectedUsers.length - 2;
return rest > 0 ? `${head} +${rest}` : head;
});
const sizeClass = computed(() => `is-${props.size}`);
function handleClick() {
if (props.disabled) return;
emit('open');
}
</script>
<template>
<div
class="user-picker-trigger"
:class="[sizeClass, { 'is-disabled': disabled }]"
role="button"
tabindex="0"
@click="handleClick"
@keydown.enter.prevent="handleClick"
@keydown.space.prevent="handleClick"
>
<span v-if="displayText" class="user-picker-trigger__text">{{ displayText }}</span>
<span v-else class="user-picker-trigger__placeholder">{{ placeholder }}</span>
<span class="user-picker-trigger__suffix">
<icon-ep:arrow-down />
</span>
</div>
</template>
<style scoped>
.user-picker-trigger {
display: inline-flex;
align-items: center;
width: 100%;
min-height: 32px;
padding: 0 30px 0 11px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
cursor: pointer;
position: relative;
box-sizing: border-box;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.user-picker-trigger.is-small {
min-height: 24px;
font-size: 12px;
}
.user-picker-trigger.is-large {
min-height: 40px;
font-size: 14px;
}
.user-picker-trigger:hover:not(.is-disabled) {
border-color: var(--el-border-color-hover);
}
.user-picker-trigger:focus-visible {
outline: none;
border-color: var(--el-color-primary);
}
.user-picker-trigger.is-disabled {
background: var(--el-disabled-bg-color);
color: var(--el-disabled-text-color);
cursor: not-allowed;
border-color: var(--el-border-color);
}
.user-picker-trigger__text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker-trigger__placeholder {
flex: 1;
min-width: 0;
color: var(--el-text-color-placeholder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker-trigger__suffix {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
color: var(--el-text-color-placeholder);
font-size: 14px;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,90 @@
import { computed, ref } from 'vue';
import { fetchGetUserManagementRelationTree } from '@/service/api';
import type { TreeCheckState } from './use-dept-source';
type ChainNode = Api.SystemManage.UserManagementRelationTreeRespVO;
export function useChainSource(selectedIds: () => Set<string>, disabledUserIdSet: () => Set<string>) {
const tree = ref<ChainNode[]>([]);
const loading = ref(false);
let loaded = false;
async function ensureLoaded() {
if (loaded) return;
loading.value = true;
try {
const { data } = await fetchGetUserManagementRelationTree({ fromUserIndex: false });
tree.value = data ?? [];
loaded = true;
} finally {
loading.value = false;
}
}
function nodeKey(node: ChainNode): string {
return node.id ?? `chain_${node.userId}`;
}
function getNodeUserIds(node: ChainNode): string[] {
const ids = new Set<string>([String(node.userId)]);
if (node.children) {
for (const c of node.children) {
for (const id of getNodeUserIds(c)) ids.add(id);
}
}
return [...ids];
}
function getNodeCheckState(node: ChainNode): TreeCheckState {
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
if (!ids.length) return 'none';
const sel = ids.filter(id => selectedIds().has(id)).length;
if (sel === 0) return 'none';
if (sel === ids.length) return 'all';
return 'partial';
}
function findNode(list: ChainNode[], key: string): ChainNode | null {
for (const n of list) {
if (nodeKey(n) === key) return n;
if (n.children) {
const r = findNode(n.children, key);
if (r) return r;
}
}
return null;
}
function matchKeyword(node: ChainNode, kw: string): boolean {
if (!kw) return true;
if (node.userNickname.toLowerCase().includes(kw)) return true;
if (node.children) return node.children.some(c => matchKeyword(c, kw));
return false;
}
function filterByKeyword(kw: string) {
const lower = kw.trim().toLowerCase();
if (!lower) return tree.value;
return tree.value.filter(n => matchKeyword(n, lower));
}
function getMetaText(node: ChainNode): string {
const total = getNodeUserIds(node).length;
return total > 1 ? `${total}` : '';
}
const treeProps = computed(() => ({ children: 'children', label: 'userNickname' }) as const);
return {
tree,
loading,
treeProps,
ensureLoaded,
getNodeUserIds,
getNodeCheckState,
findNode,
filterByKeyword,
getMetaText,
nodeKey
};
}

View File

@@ -0,0 +1,99 @@
import { computed, ref } from 'vue';
import { fetchGetDeptSimpleList } from '@/service/api';
import { buildMenuTree } from '@/views/system/shared/menu-tree';
export type TreeCheckState = 'none' | 'partial' | 'all';
export function useDeptSource(
userOptions: () => Api.SystemManage.UserSimple[],
selectedIds: () => Set<string>,
disabledUserIdSet: () => Set<string>
) {
const tree = ref<Api.SystemManage.DeptSimple[]>([]);
const loading = ref(false);
let loaded = false;
async function ensureLoaded() {
if (loaded) return;
loading.value = true;
try {
const { data } = await fetchGetDeptSimpleList();
tree.value = data ? buildMenuTree(data) : [];
loaded = true;
} finally {
loading.value = false;
}
}
function collectDeptIds(node: Api.SystemManage.DeptSimple): string[] {
const ids: string[] = [String(node.id)];
if (node.children) {
for (const c of node.children) ids.push(...collectDeptIds(c));
}
return ids;
}
function getNodeUserIds(node: Api.SystemManage.DeptSimple): string[] {
const deptIds = new Set(collectDeptIds(node));
return userOptions()
.filter(u => u.deptId !== null && u.deptId !== undefined && deptIds.has(String(u.deptId)))
.map(u => String(u.id));
}
function getNodeCheckState(node: Api.SystemManage.DeptSimple): TreeCheckState {
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
if (!ids.length) return 'none';
const sel = ids.filter(id => selectedIds().has(id)).length;
if (sel === 0) return 'none';
if (sel === ids.length) return 'all';
return 'partial';
}
function findNode(list: Api.SystemManage.DeptSimple[], key: string): Api.SystemManage.DeptSimple | null {
for (const n of list) {
if (String(n.id) === key) return n;
if (n.children) {
const r = findNode(n.children, key);
if (r) return r;
}
}
return null;
}
function matchKeyword(node: Api.SystemManage.DeptSimple, kw: string): boolean {
if (!kw) return true;
if (node.name.toLowerCase().includes(kw)) return true;
if (node.children) return node.children.some(c => matchKeyword(c, kw));
return false;
}
function filterByKeyword(kw: string) {
const lower = kw.trim().toLowerCase();
if (!lower) return tree.value;
return tree.value.filter(n => matchKeyword(n, lower));
}
function getMetaText(node: Api.SystemManage.DeptSimple): string {
const total = getNodeUserIds(node).length;
return total > 0 ? `${total}` : '';
}
function nodeKey(node: Api.SystemManage.DeptSimple): string {
return String(node.id);
}
const treeProps = computed(() => ({ children: 'children', label: 'name' }) as const);
return {
tree,
loading,
treeProps,
ensureLoaded,
getNodeUserIds,
getNodeCheckState,
findNode,
filterByKeyword,
getMetaText,
nodeKey
};
}

View File

@@ -0,0 +1,89 @@
import { computed, ref } from 'vue';
export interface PickerSelectionOptions {
multiple: boolean;
}
export function usePickerSelection(options: () => PickerSelectionOptions) {
const multiSet = ref<Set<string>>(new Set());
const singleId = ref<string | null>(null);
const multiple = computed(() => options().multiple);
function has(userId: string): boolean {
if (multiple.value) return multiSet.value.has(userId);
return singleId.value === userId;
}
function toggle(userId: string) {
if (multiple.value) {
if (multiSet.value.has(userId)) multiSet.value.delete(userId);
else multiSet.value.add(userId);
multiSet.value = new Set(multiSet.value);
} else {
singleId.value = singleId.value === userId ? null : userId;
}
}
function addMany(userIds: readonly string[]) {
if (!multiple.value) {
singleId.value = userIds[0] ?? singleId.value;
return;
}
for (const id of userIds) multiSet.value.add(id);
multiSet.value = new Set(multiSet.value);
}
function removeMany(userIds: readonly string[]) {
if (!multiple.value) {
if (singleId.value && userIds.includes(singleId.value)) singleId.value = null;
return;
}
for (const id of userIds) multiSet.value.delete(id);
multiSet.value = new Set(multiSet.value);
}
function clear(preserveIds?: readonly string[]) {
const keep = new Set((preserveIds ?? []).map(String));
if (multiple.value) {
const next = new Set<string>();
for (const id of multiSet.value) {
if (keep.has(id)) next.add(id);
}
multiSet.value = next;
} else if (singleId.value && !keep.has(singleId.value)) singleId.value = null;
}
function reset(initial: string | string[] | null | undefined) {
if (multiple.value) {
const ids = Array.isArray(initial) ? initial.map(String) : [];
multiSet.value = new Set(ids);
} else {
singleId.value = typeof initial === 'string' ? initial : null;
}
}
const selectedIds = computed<string[]>(() => {
if (multiple.value) return [...multiSet.value];
return singleId.value ? [singleId.value] : [];
});
const size = computed(() => selectedIds.value.length);
function commit(): string | string[] | null {
if (multiple.value) return [...multiSet.value];
return singleId.value;
}
return {
selectedIds,
size,
has,
toggle,
addMany,
removeMany,
clear,
reset,
commit
};
}

View File

@@ -0,0 +1,457 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useInfiniteScroll } from '@vueuse/core';
defineOptions({ name: 'NotificationBell' });
interface NotificationItem {
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const PAGE_SIZE = 10;
// 通知 mock扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
function buildMockNotifications(): NotificationItem[] {
const titles = [
'你被指派为执行「迭代 24.06」负责人',
'任务「SSO 改造」状态变更:开发中 → 待验收',
'需求「多币种支持」评审通过',
'工单 #1042 已分派给你',
'需求「订单导出」被退回,请补充材料',
'@ 你的评论已被回复',
'项目「客户中心 2.0」周报已生成',
'工单 #1098 客户回复待处理',
'执行「迭代 24.05」已结束',
'需求「批量审批」分配给你'
];
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
return Array.from({ length: 60 }, (_, i) => ({
id: `m${i + 1}`,
title: `${titles[i % titles.length]}#${i + 1}`,
timeLabel: times[Math.floor(i / 6) % times.length],
unread: i < 14
}));
}
const notifications = ref<NotificationItem[]>(buildMockNotifications());
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
const readAll = computed(() => notifications.value.filter(n => !n.unread));
const unreadCount = computed(() => unreadAll.value.length);
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
const drawerOpen = ref(false);
const activeTab = ref<'unread' | 'read'>('unread');
const searchKeyword = ref('');
function matchesKeyword(item: NotificationItem) {
const kw = searchKeyword.value.trim();
if (!kw) return true;
return item.title.toLowerCase().includes(kw.toLowerCase());
}
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
const unreadPageSize = ref(PAGE_SIZE);
const readPageSize = ref(PAGE_SIZE);
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
watch(searchKeyword, () => {
unreadPageSize.value = PAGE_SIZE;
readPageSize.value = PAGE_SIZE;
});
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
const unreadScrollbar = ref<ScrollbarRefValue>(null);
const readScrollbar = ref<ScrollbarRefValue>(null);
useInfiniteScroll(
() => unreadScrollbar.value?.wrapRef,
() => {
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
},
{ distance: 48 }
);
useInfiniteScroll(
() => readScrollbar.value?.wrapRef,
() => {
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
},
{ distance: 48 }
);
function openDrawer() {
drawerOpen.value = true;
}
function closeDrawer() {
drawerOpen.value = false;
}
function markRead(item: NotificationItem) {
if (!item.unread) return;
item.unread = false;
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', item.id);
}
function markAllRead() {
notifications.value.forEach(item => {
item.unread = false;
});
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
function openItem(item: NotificationItem) {
markRead(item);
// eslint-disable-next-line no-console
console.warn('[notification] open', item.id);
}
function onDrawerClosed() {
searchKeyword.value = '';
}
</script>
<template>
<button
class="notification-bell__trigger"
type="button"
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
@click="openDrawer"
>
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
</button>
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed">
<div class="notification-bell__panel">
<header class="notification-bell__header">
<span class="notification-bell__title">
通知
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
</span>
<span class="notification-bell__header-actions">
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
<SvgIcon icon="mdi:close" />
</button>
</span>
</header>
<div class="notification-bell__search">
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
<template #prefix>
<SvgIcon icon="mdi:magnify" />
</template>
</ElInput>
</div>
<ElTabs v-model="activeTab" class="notification-bell__tabs">
<ElTabPane name="unread">
<template #label>
<span class="notification-bell__tab-label">
未读
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span>
</span>
</template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
<li
v-for="row in visibleUnread"
:key="row.id"
class="notification-bell__row is-unread"
@click="openItem(row)"
>
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
</div>
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
<ElTabPane name="read">
<template #label>
<span class="notification-bell__tab-label">
已读
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
</span>
</template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.title }}</div>
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
</div>
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
</ElDrawer>
</template>
<style scoped>
.notification-bell__trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
margin: 0 4px;
border: none;
border-radius: 8px;
background-color: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease;
}
.notification-bell__trigger:hover {
background-color: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.notification-bell__trigger:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
.notification-bell__icon {
font-size: 20px;
}
.notification-bell__badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 16px;
text-align: center;
}
.notification-bell__panel {
display: flex;
flex-direction: column;
height: 100%;
}
.notification-bell__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.notification-bell__title {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.notification-bell__title-count {
padding: 1px 8px;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 11px;
font-weight: 600;
}
.notification-bell__header-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.notification-bell__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 18px;
transition:
background-color 120ms ease,
color 120ms ease;
}
.notification-bell__close:hover {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-primary);
}
.notification-bell__search {
padding: 12px 0 4px;
}
.notification-bell__tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.notification-bell__tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.notification-bell__tabs :deep(.el-tab-pane) {
height: 100%;
}
.notification-bell__tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.notification-bell__tab-count {
padding: 0 7px;
border-radius: 999px;
background-color: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.notification-bell__scroll {
height: 100%;
}
.notification-bell__list {
margin: 0;
padding: 0;
list-style: none;
}
.notification-bell__row {
position: relative;
display: grid;
grid-template-columns: 14px minmax(0, 1fr);
align-items: flex-start;
gap: 10px;
padding: 12px 4px;
cursor: pointer;
border-radius: 8px;
transition: background-color 120ms ease;
}
.notification-bell__row + .notification-bell__row {
border-top: 1px dashed var(--el-border-color-lighter);
}
.notification-bell__row:hover {
background-color: var(--el-fill-color-light);
}
.notification-bell__row-dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 50%;
background-color: transparent;
justify-self: center;
}
.notification-bell__row.is-unread .notification-bell__row-dot {
background-color: var(--el-color-primary);
}
.notification-bell__row-body {
min-width: 0;
}
.notification-bell__row-title {
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.5;
}
.notification-bell__row.is-unread .notification-bell__row-title {
color: var(--el-text-color-primary);
font-weight: 500;
}
.notification-bell__row-time {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.notification-bell__empty {
padding: 48px 16px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.notification-bell__footer-hint {
padding: 12px 0 4px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 12px;
user-select: none;
}
</style>

View File

@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
import GlobalSearch from '../global-search/index.vue';
import ThemeButton from './components/theme-button.vue';
import NotificationBell from './components/notification-bell.vue';
import UserAvatar from './components/user-avatar.vue';
defineOptions({ name: 'GlobalHeader' });
@@ -48,6 +49,7 @@ const { isFullscreen, toggle } = useFullscreen();
<div>
<ThemeButton />
</div>
<NotificationBell />
<UserAvatar />
</div>
</DarkModeContainer>

View File

@@ -1,4 +1,3 @@
import type { RouteMeta } from 'vue-router';
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
import { objectContextDomainConfigs } from '@/constants/object-context';
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';

View File

@@ -19,6 +19,7 @@ declare module 'vue' {
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default']
@@ -181,6 +182,7 @@ declare module 'vue' {
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
}

View File

@@ -30,6 +30,7 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
defaultTab: 'worklog'
});

View File

@@ -2,7 +2,7 @@
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import BusinessUserPicker from '@/components/custom/business-user-picker.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProductCreateBaseForm' });
@@ -72,9 +72,10 @@ defineExpose({ validate: runValidate });
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品经理" prop="managerUserId">
<BusinessUserSelect
<BusinessUserPicker
v-model="model.managerUserId"
:options="managerUserOptions"
:user-options="managerUserOptions"
title="选择产品经理"
placeholder="请选择产品经理"
/>
</ElFormItem>

View File

@@ -17,7 +17,14 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
level: 0
level: 0,
selectedModuleId: undefined,
editingNodeId: undefined,
editingName: undefined,
addingChildParentId: undefined,
newChildModuleName: undefined,
rootModuleId: undefined,
moduleRequirementCountMap: undefined
});
const emit = defineEmits([

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { fetchSplitRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';

View File

@@ -6,7 +6,7 @@ import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/
import { fetchGetProductPage } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import BusinessUserPicker from '@/components/custom/business-user-picker.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProjectCreateBaseForm' });
@@ -247,9 +247,10 @@ defineExpose({ validate: runValidate });
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目经理" prop="managerUserId">
<BusinessUserSelect
<BusinessUserPicker
v-model="model.managerUserId"
:options="managerUserOptions"
:user-options="managerUserOptions"
title="选择项目经理"
placeholder="请选择项目经理"
/>
</ElFormItem>

View File

@@ -8,7 +8,6 @@ import {
fetchGetProjectTaskWorklogPage,
fetchUpdateProjectTaskWorklog
} from '@/service/api/project';
import type { ServiceRequestResult } from '@/service/api/shared';
import { useAuthStore } from '@/store/modules/auth';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
@@ -78,8 +77,15 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
taskProgressRate: 0,
assignees: null,
externalList: null,
ownerNickname: null,
showAssigneeColumn: false,
active: true,
fetchWorklogPage: undefined,
createWorklog: undefined,
updateWorklog: undefined,
deleteWorklog: undefined,
attachmentDirectory: 'task-worklog',
createSuccessMessage: '填报成功',
updateSuccessMessage: '填报已更新',

View File

@@ -17,7 +17,14 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
level: 0
level: 0,
selectedModuleId: undefined,
editingNodeId: undefined,
editingName: undefined,
addingChildParentId: undefined,
newChildModuleName: undefined,
rootModuleId: undefined,
moduleRequirementCountMap: undefined
});
const emit = defineEmits([

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { fetchSplitProjectRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';

View File

@@ -1,5 +1,5 @@
import type { LayoutStorage } from './layout-storage';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
import type { WorkbenchLayout } from './workbench-layout-types';
const KEY_PREFIX = 'rdms-workbench-layout';
@@ -8,14 +8,14 @@ function buildKey(userId: string) {
}
export class LocalStorageAdapter implements LayoutStorage {
// 版本校验交给上层use-workbench-layout.load版本不匹配时上层需要拿到旧 settings 做迁移,
// 这里直接返回 raw 解析结果,只过滤掉无法解析的脏数据
// eslint-disable-next-line class-methods-use-this
async load(userId: string): Promise<WorkbenchLayout | null> {
try {
const raw = window.localStorage.getItem(buildKey(userId));
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkbenchLayout;
if (parsed?.version !== WORKBENCH_LAYOUT_VERSION) return null;
return parsed;
return JSON.parse(raw) as WorkbenchLayout;
} catch {
return null;
}

View File

@@ -0,0 +1,34 @@
// 工作台跨 widget 共享的"项目色注册器"。
// 同一个 projectKey 在不同 widgetC12 团队工时分布、C13 团队负载、D16 我的本周工时…)
// 颜色保持一致,形成系统级"项目色码"。
//
// 分配策略:按首次访问顺序循环取 PROJECT_COLORSpersonal/other 固定色。
// 整页生命周期内稳定;新增项目自动追加。
// 项目色:纯分类色,不含红/橙系。告警语义留给等级色(红/橙/绿),避免视觉混淆。
const PROJECT_COLORS = ['#5B8FF9', '#5AD8A6', '#5D7092', '#F6BD16', '#6DC8EC', '#73D13D', '#36CFC9', '#36495D'];
// 个人事项独占紫色,与项目色明显区分
const PERSONAL_COLOR = '#9254DE';
const OTHER_COLOR = '#BFBFBF';
export type WorkbenchItemKind = 'project' | 'personal' | 'other';
const projectColorMap = new Map<string, string>();
export function getWorkbenchProjectColor(projectKey: string): string {
let color = projectColorMap.get(projectKey);
if (!color) {
color = PROJECT_COLORS[projectColorMap.size % PROJECT_COLORS.length];
projectColorMap.set(projectKey, color);
}
return color;
}
export function getWorkbenchItemColor(key: string, kind: WorkbenchItemKind): string {
if (kind === 'personal') return PERSONAL_COLOR;
if (kind === 'other') return OTHER_COLOR;
return getWorkbenchProjectColor(key);
}
export const WORKBENCH_PERSONAL_COLOR = PERSONAL_COLOR;
export const WORKBENCH_OTHER_COLOR = OTHER_COLOR;

View File

@@ -5,7 +5,7 @@ import { buildDefaultLayout } from './workbench-layout-default';
import type { LayoutStorage } from './layout-storage';
import { LocalStorageAdapter } from './layout-storage-local';
import { reconcileLayout } from './workbench-layout-reconcile';
import type { WorkbenchLayout } from './workbench-layout-types';
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
export type WorkbenchMode = 'normal' | 'editing';
@@ -28,7 +28,16 @@ export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
async function load() {
const fromStorage = await storage.load(options.userId);
layout.value = reconcileLayout(fromStorage ?? buildDefaultLayout(getAllModules()), getAllModules());
if (fromStorage && fromStorage.version === WORKBENCH_LAYOUT_VERSION) {
layout.value = reconcileLayout(fromStorage, getAllModules());
return;
}
// 版本不匹配 / 无存储:走新默认布局;旧 settings 迁移过来,避免用户偏好(如 shortcut.menuKeys被 version bump 清空
const fresh = buildDefaultLayout(getAllModules());
if (fromStorage?.settings) {
fresh.settings = { ...fromStorage.settings };
}
layout.value = fresh;
}
const persist = useDebounceFn(async () => {

View File

@@ -4,32 +4,17 @@ import { markRaw, shallowRef } from 'vue';
export type WorkbenchModuleKey =
// 保留:现有 key 沿用(避免影响线上用户布局存储)
| 'myTodo' // A1 · 我的待办
| 'myRequirement' // B9 · 我的需求
| 'myProject' // B7 · 我参与的项目
| 'shortcut' // E19 · 快捷入口
| 'projectHealth' // C15 · 产品 / 项目健康度
| 'favorite' // E21 · 我的收藏 / 关注
// 重构key 沿用,组件内容重写)
| 'myTask' // A2 · 我的今日(原"我的任务"
| 'teamTodo' // C11 · 团队任务看板(原"团队待办汇总"
// 新增 15 个(蓝图 2026-05-22原 A3 myTicket 已废弃:与"我的待办 → 工单"重复且工单业务未上线)
| 'mentions' // A4 · @我的提及
| 'approval' // A5 · 待审批(管理者)
| 'worklogReminder' // A6 · 工时填报提醒(动作型)
// 新增(蓝图 2026-05-22原 A3 myTicket、A4 mentions、A5 approval、A6 worklogReminder、B10 personalItem、F23 projectSnapshot、C11 teamTodo、E21 favorite 已废弃F23 已并入 B7 myProject "我负责的" tab
| 'myExecution' // B8 · 我负责的执行
| 'personalItem' // B10 · 我的个人事项
| 'projectSnapshot' // F23 · 项目深度快照(对象快照 / pin
| 'productSnapshot' // F24 · 产品深度快照(对象快照 / pin
| 'teamWorklog' // C12 · 团队工时分布(管理者)
| 'productSnapshot' // F24 · 产品深度快照(对象快照 / 当前对象切换)
| 'teamLoad' // C13 · 团队负载(管理者)
| 'riskAlert' // C14 · 风险预警(管理者
| 'myWeekWorklog' // D16 · 我的本周工时
| 'myCompletionRate' // D17 · 我的完成率
| 'ticketSla' // D18 · 工单 SLA 总览(管理者 + 工单待开发)
| 'recentVisit' // E20 · 最近访问
| 'myWeekWorklog' // D16 · 工时(含「我的工时 / 团队工时」两 tab原 C12 teamWorklog 已并入
| 'noticeNotification'; // E22 · 公告 + 通知摘要
// 扩展action动作型 widget、snapshot对象快照型 widget pin 一个对象)
// 扩展action动作型 widget、snapshot对象快照型 widget指定一个对象)
export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool' | 'action' | 'snapshot';
export type WorkbenchColumnId = 'left' | 'right';
@@ -46,8 +31,12 @@ export interface WorkbenchModuleMeta {
const placeholder = markRaw({ render: () => null });
// 默认布局2026-05-27 调整,对应 WORKBENCH_LAYOUT_VERSION=3
// left: myTodo(1) → myExecution(2)
// right: shortcut(1) → myProject(2) → myWeekWorklog(3) → teamLoad(4)
// hidden: projectHealth, noticeNotification, productSnapshot
// noticeNotification 隐藏原因:公告搬到 banner、通知归全局头部铃铛
const registry: WorkbenchModuleMeta[] = [
// === 保留 6 个:默认布局沿用原配置,用户线上布局 0 影响 ===
{
key: 'myTodo',
component: placeholder,
@@ -59,35 +48,15 @@ const registry: WorkbenchModuleMeta[] = [
defaultOrder: 1
},
{
key: 'myTask',
key: 'myExecution',
component: placeholder,
displayName: '我的今日',
icon: 'mdi:calendar-check-outline',
displayName: '我负责的执行',
icon: 'mdi:flag-checkered',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 2
},
{
key: 'myRequirement',
component: placeholder,
displayName: '我的需求',
icon: 'mdi:file-document-multiple-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'left',
defaultOrder: 3
},
{
key: 'myProject',
component: placeholder,
displayName: '我参与的项目',
icon: 'mdi:briefcase-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 1
},
{
key: 'shortcut',
component: placeholder,
@@ -96,8 +65,39 @@ const registry: WorkbenchModuleMeta[] = [
category: 'tool',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 1
},
{
key: 'myProject',
component: placeholder,
displayName: '我的项目',
icon: 'mdi:briefcase-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 2
},
{
key: 'myWeekWorklog',
component: placeholder,
displayName: '工时',
icon: 'mdi:timer-outline',
category: 'personal',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 3
},
{
key: 'teamLoad',
component: placeholder,
displayName: '团队负载',
icon: 'mdi:scale-balance',
category: 'manager',
defaultVisible: true,
defaultColumn: 'right',
defaultOrder: 4
},
// === 默认隐藏(用户可从 widget 库拖回) ===
{
key: 'projectHealth',
component: placeholder,
@@ -109,86 +109,14 @@ const registry: WorkbenchModuleMeta[] = [
defaultOrder: 10
},
{
key: 'teamTodo',
key: 'noticeNotification',
component: placeholder,
displayName: '团队任务看板',
icon: 'mdi:view-column-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 11
},
{
key: 'favorite',
component: placeholder,
displayName: '我的收藏 / 关注',
icon: 'mdi:star-outline',
displayName: '公告 + 通知',
icon: 'mdi:bullhorn-outline',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 30
},
// === 新增 15 个:默认全部 hidden进 widget 库待用户挑(避免一上来挤爆工作台) ===
{
key: 'mentions',
component: placeholder,
displayName: '@我的提及',
icon: 'mdi:at',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 21
},
{
key: 'approval',
component: placeholder,
displayName: '待审批',
icon: 'mdi:checkbox-multiple-marked-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 22
},
{
key: 'worklogReminder',
component: placeholder,
displayName: '工时填报提醒',
icon: 'mdi:timer-sand',
category: 'action',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 20
},
{
key: 'myExecution',
component: placeholder,
displayName: '我负责的执行',
icon: 'mdi:flag-checkered',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 23
},
{
key: 'personalItem',
component: placeholder,
displayName: '我的个人事项',
icon: 'mdi:format-list-checks',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 24
},
{
key: 'projectSnapshot',
component: placeholder,
displayName: '项目深度快照',
icon: 'mdi:image-area',
category: 'snapshot',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 40
defaultOrder: 11
},
{
key: 'productSnapshot',
@@ -199,86 +127,6 @@ const registry: WorkbenchModuleMeta[] = [
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 41
},
{
key: 'teamWorklog',
component: placeholder,
displayName: '团队工时分布',
icon: 'mdi:chart-bar',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 12
},
{
key: 'teamLoad',
component: placeholder,
displayName: '团队负载',
icon: 'mdi:scale-balance',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 13
},
{
key: 'riskAlert',
component: placeholder,
displayName: '风险预警',
icon: 'mdi:alert-octagon-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 14
},
{
key: 'myWeekWorklog',
component: placeholder,
displayName: '我的本周工时',
icon: 'mdi:chart-line',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 25
},
{
key: 'myCompletionRate',
component: placeholder,
displayName: '我的完成率',
icon: 'mdi:chart-donut',
category: 'personal',
defaultVisible: false,
defaultColumn: 'left',
defaultOrder: 26
},
{
key: 'ticketSla',
component: placeholder,
displayName: '工单 SLA 总览',
icon: 'mdi:timer-alert-outline',
category: 'manager',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 15
},
{
key: 'recentVisit',
component: placeholder,
displayName: '最近访问',
icon: 'mdi:history',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 31
},
{
key: 'noticeNotification',
component: placeholder,
displayName: '公告 + 通知',
icon: 'mdi:bullhorn-outline',
category: 'tool',
defaultVisible: false,
defaultColumn: 'right',
defaultOrder: 32
}
];

View File

@@ -1,6 +1,8 @@
import type { WorkbenchColumnId, WorkbenchModuleKey } from './use-workbench-modules';
export const WORKBENCH_LAYOUT_VERSION = 1;
// v3 (2026-05-27): myProject 移到右列、myExecution 顶替到 left 第 2 位、noticeNotification 默认隐藏(让位给 banner 公告 + 全局铃铛)。
// 版本不匹配时 LocalStorageAdapter.load 直接丢弃存量布局走新默认。
export const WORKBENCH_LAYOUT_VERSION = 3;
export interface WorkbenchShortcutSettings {
/** 用户在快捷入口里选了哪些菜单 key */

View File

@@ -2,7 +2,7 @@ import dayjs from 'dayjs';
export type WorkbenchTrend = 'up' | 'down' | 'flat';
export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'review';
export type WorkbenchTodoCategory = 'task' | 'ticket' | 'personal' | 'approval';
export type WorkbenchTodoMainTab = 'all' | WorkbenchTodoCategory;
@@ -12,32 +12,6 @@ export type WorkbenchTodoPriority = 'high' | 'mid' | 'low';
export type WorkbenchProjectStatus = 'active' | 'preview' | 'paused';
export interface WorkbenchBannerSummarySource {
/** 今日待办数 */
todoCount: number;
/** 即将到期数(今日/明日内截止) */
upcomingCount: number;
/** 本周已完成 */
weekDone: number;
/** 本周总量 */
weekTotal: number;
/** 本周进行中 */
weekInProgress: number;
/** 本周逾期 */
weekOverdue: number;
}
export interface WorkbenchBannerSummary {
todoCount: number;
upcomingCount: number;
weekDone: number;
weekTotal: number;
weekInProgress: number;
weekOverdue: number;
/** 完成率0-100 整数) */
weekCompletionRate: number;
}
export interface WorkbenchKpiSource {
/** 待办 */
todo: {
@@ -149,7 +123,7 @@ const todoCategoryMeta: Record<
task: { label: '任务', tone: 'emerald', icon: 'mdi:checkbox-marked-circle-outline' },
ticket: { label: '工单', tone: 'amber', icon: 'mdi:ticket-confirmation-outline' },
personal: { label: '个人事项', tone: 'violet', icon: 'mdi:notebook-edit-outline' },
review: { label: '待审', tone: 'sky', icon: 'mdi:file-search-outline' }
approval: { label: '待审', tone: 'sky', icon: 'mdi:checkbox-multiple-marked-outline' }
};
const todoPriorityWeight: Record<WorkbenchTodoPriority, number> = {
@@ -221,22 +195,6 @@ function getRemainingDays(value: string | null) {
return target.startOf('day').diff(dayjs().startOf('day'), 'day');
}
export function buildWorkbenchBannerSummary(source: WorkbenchBannerSummarySource): WorkbenchBannerSummary {
const total = Math.max(0, source.weekTotal);
const done = Math.max(0, source.weekDone);
const rate = total === 0 ? 0 : clampPercent((done / total) * 100);
return {
todoCount: Math.max(0, source.todoCount),
upcomingCount: Math.max(0, source.upcomingCount),
weekDone: done,
weekTotal: total,
weekInProgress: Math.max(0, source.weekInProgress),
weekOverdue: Math.max(0, source.weekOverdue),
weekCompletionRate: rate
};
}
export function buildWorkbenchKpiCards(source: WorkbenchKpiSource): WorkbenchKpiCard[] {
function diffTrend(diff: number): { trend: WorkbenchTrend; text: string } {
if (diff > 0) return { trend: 'up', text: `较昨日 +${diff}` };
@@ -376,6 +334,134 @@ export function buildWorkbenchProjectItems(source: readonly WorkbenchProjectItem
});
}
export interface WorkbenchOwnedProjectMilestone {
id: string;
title: string;
timeLabel: string;
tone: 'amber' | 'slate';
}
export interface WorkbenchOwnedProjectMember {
name: string;
/** 负载 0-100百分比 */
load: number;
level: 'ok' | 'warn' | 'over';
}
export interface WorkbenchOwnedProjectItemSource {
id: string;
name: string;
code: string;
/** 进度 0-100 */
progress: number;
executionCount: number;
taskCount: number;
memberCount: number;
overdueCount: number;
/** 距离计划结束剩余天数(负数表示已逾期) */
remainingDays: number;
/** 我在该项目中的角色 */
myRole: string;
milestones: WorkbenchOwnedProjectMilestone[];
members: WorkbenchOwnedProjectMember[];
}
export interface WorkbenchOwnedProjectItem extends WorkbenchOwnedProjectItemSource {
progress: number;
}
export function buildWorkbenchOwnedProjectItems(
source: readonly WorkbenchOwnedProjectItemSource[]
): WorkbenchOwnedProjectItem[] {
return source.map(item => ({
...item,
progress: clampPercent(item.progress)
}));
}
/** 工时分布行项:项目 / 个人事项 / 其他(杂项) */
export interface WorkbenchWorklogDistributionItem {
/** 唯一 keyproject 用 projectIdpersonal/other 用固定字面量 */
key: string;
label: string;
hours: number;
kind: 'project' | 'personal' | 'other';
/** kind === 'project' 时携带,用于跳转项目对象上下文 */
projectId?: string;
}
/** 单周工时数据源(按天填 / 按周填两类共存) */
export interface WorkbenchWeekWorklogSource {
/** ISO 周首日周一YYYY-MM-DD */
weekStart: string;
/** 整周一笔填的工时总和(将按工作日 5 等分均摊到周一~周五) */
weeklyFilledHours: number;
/** 周一~周五按天填的工时,长度恒为 5 */
dailyHours: [number, number, number, number, number];
/** 工时分布;前端不再做截断,超出 5 行靠容器滚动 */
distribution: WorkbenchWorklogDistributionItem[];
/** 周目标小时数(默认 40 */
target: number;
}
/** 双周工时 mock 容器:本周 + 上周 */
export interface WorkbenchMyWeekWorklogSource {
current: WorkbenchWeekWorklogSource;
previous: WorkbenchWeekWorklogSource;
}
/** 单周工时视图builder 衍生) */
export interface WorkbenchWeekWorklogView {
weekStart: string;
weekLabel: string;
/** 周一~周五按天填部分5 长度) */
dailyByDay: number[];
/** 周一~周五按周均分部分5 长度,每项 = weeklyFilledHours / 5 */
dailyByWeekAvg: number[];
/** 周一~周五合计5 长度dailyByDay + dailyByWeekAvg */
dailyTotal: number[];
/** 累计(含按周) */
totalHours: number;
target: number;
/** 累计 - 目标;正=领先、负=落后 */
delta: number;
/** 达成率0-100 整数 */
completionRate: number;
distribution: WorkbenchWorklogDistributionItem[];
}
const WEEKDAY_COUNT = 5;
function roundHours(value: number) {
return Math.round(value * 10) / 10;
}
export function buildWorkbenchWeekWorklogView(source: WorkbenchWeekWorklogSource): WorkbenchWeekWorklogView {
const start = dayjs(source.weekStart);
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : source.weekStart;
const weekAvg = roundHours(source.weeklyFilledHours / WEEKDAY_COUNT);
const dailyByWeekAvg = Array.from({ length: WEEKDAY_COUNT }, () => weekAvg);
const dailyByDay = source.dailyHours.map(roundHours);
const dailyTotal = dailyByDay.map((h, i) => roundHours(h + dailyByWeekAvg[i]));
const totalHours = roundHours(dailyTotal.reduce((s, h) => s + h, 0));
const delta = roundHours(totalHours - source.target);
const completionRate = source.target > 0 ? clampPercent((totalHours / source.target) * 100) : 0;
return {
weekStart: source.weekStart,
weekLabel,
dailyByDay,
dailyByWeekAvg,
dailyTotal,
totalHours,
target: source.target,
delta,
completionRate,
distribution: source.distribution
};
}
export function getGreeting(hour: number = dayjs().hour()) {
if (hour < 6) return '凌晨好';
if (hour < 11) return '早上好';
@@ -385,85 +471,252 @@ export function getGreeting(hour: number = dayjs().hour()) {
return '夜深了';
}
export function getTodayLabel() {
const today = dayjs();
const weekdayMap = ['日', '一', '二', '三', '四', '五', '六'];
return `今天 ${today.format('YYYY-MM-DD')} 星期${weekdayMap[today.day()]}`;
// === 团队工时分布C12 · teamWorklog ===
export type WorkbenchTeamWorklogItemKind = 'project' | 'personal' | 'other';
export interface WorkbenchTeamWorklogItem {
/** 项目 id 或 'personal' / 'other' */
key: string;
label: string;
hours: number;
kind: WorkbenchTeamWorklogItemKind;
}
export type WorkbenchMyTaskBucket = 'today' | 'week' | 'overdue' | 'all';
export interface WorkbenchMyTaskItemSource {
id: string;
title: string;
statusCode: string;
statusLabel: string;
executionName: string;
projectName: string;
priority: 'high' | 'mid' | 'low';
deadline: string | null;
}
export interface WorkbenchMyTaskItem extends Omit<WorkbenchMyTaskItemSource, 'deadline'> {
deadlineLabel: string;
remainingDays: number | null;
overdue: boolean;
}
export function buildWorkbenchMyTaskItems(source: readonly WorkbenchMyTaskItemSource[]): WorkbenchMyTaskItem[] {
return [...source]
.sort((a, b) => {
const av = a.deadline ? dayjs(a.deadline).valueOf() : Number.POSITIVE_INFINITY;
const bv = b.deadline ? dayjs(b.deadline).valueOf() : Number.POSITIVE_INFINITY;
return av - bv;
})
.map(item => {
const remaining = getRemainingDays(item.deadline);
return {
...item,
deadlineLabel: formatDeadline(item.deadline),
remainingDays: remaining,
overdue: remaining !== null && remaining < 0
} satisfies WorkbenchMyTaskItem;
});
}
export function filterWorkbenchMyTaskItems(items: readonly WorkbenchMyTaskItem[], bucket: WorkbenchMyTaskBucket) {
if (bucket === 'all') return [...items];
if (bucket === 'overdue') return items.filter(i => i.overdue);
if (bucket === 'today') return items.filter(i => i.remainingDays === 0);
return items.filter(i => i.remainingDays !== null && i.remainingDays >= 0 && i.remainingDays <= 7);
}
export interface WorkbenchMyRequirementGroupSource {
statusCode: string;
statusLabel: string;
count: number;
tone: 'sky' | 'amber' | 'emerald' | 'rose';
}
export type WorkbenchMyRequirementGroup = WorkbenchMyRequirementGroupSource;
export function buildWorkbenchMyRequirementGroups(
source: readonly WorkbenchMyRequirementGroupSource[]
): WorkbenchMyRequirementGroup[] {
return [...source];
}
export interface WorkbenchTeamTodoRowSource {
projectId: string;
projectName: string;
export interface WorkbenchTeamWorklogMemberSource {
memberId: string;
memberName: string;
inProgress: number;
overdue: number;
weekDone: number;
items: WorkbenchTeamWorklogItem[];
}
export type WorkbenchTeamTodoRow = WorkbenchTeamTodoRowSource;
export interface WorkbenchTeamWorklogSource {
weekStart: string;
/** 单人周应填工时(用于填报率口径) */
expectedHoursPerMember: number;
members: WorkbenchTeamWorklogMemberSource[];
}
export function buildWorkbenchTeamTodoRows(source: readonly WorkbenchTeamTodoRowSource[]): WorkbenchTeamTodoRow[] {
return [...source].sort((a, b) => b.overdue - a.overdue);
/** 团队工时分布视图builder 衍生) */
export interface WorkbenchTeamWorklogView {
weekStart: string;
weekLabel: string;
members: Array<{
memberId: string;
memberName: string;
totalHours: number;
}>;
/** 项目/个人/其他 列(保序,去重) */
categories: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>;
/** 每个 category 一个 seriesdata[i] = 第 i 个成员该 category 的工时(缺则 0 */
seriesMatrix: Array<{ key: string; label: string; kind: WorkbenchTeamWorklogItemKind; data: number[] }>;
/** 团队总工时 */
totalHours: number;
/** 团队人均工时 */
averageHours: number;
/** 应填工时合计(人数 × expectedHoursPerMember */
expectedTotalHours: number;
/** 填报率 0-100 整数(= 总工时 / 应填工时) */
fillRate: number;
/** 偏低人数:< 团队均值 × 0.8 */
lowCount: number;
/** 加班人数:> 45h应填 × 1.125 */
highCount: number;
/** 工时最低成员(用于底部小结) */
lowest: { memberName: string; hours: number } | null;
/** 工时最高成员(用于底部小结) */
highest: { memberName: string; hours: number } | null;
}
export function buildWorkbenchTeamWorklogView(source: WorkbenchTeamWorklogSource): WorkbenchTeamWorklogView {
const start = dayjs(source.weekStart);
const weekLabel = start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : source.weekStart;
// 列保序去重:按成员遍历顺序首次出现即入列;项目优先、个人/其他按出现顺序追加
const categoryMap = new Map<string, { key: string; label: string; kind: WorkbenchTeamWorklogItemKind }>();
for (const m of source.members) {
for (const it of m.items) {
if (!categoryMap.has(it.key)) {
categoryMap.set(it.key, { key: it.key, label: it.label, kind: it.kind });
}
}
}
const categories = Array.from(categoryMap.values());
const memberView = source.members.map(m => ({
memberId: m.memberId,
memberName: m.memberName,
totalHours: roundHours(m.items.reduce((s, it) => s + it.hours, 0))
}));
const seriesMatrix = categories.map(cat => ({
...cat,
data: source.members.map(m => {
const hit = m.items.find(it => it.key === cat.key);
return hit ? roundHours(hit.hours) : 0;
})
}));
const totalHours = roundHours(memberView.reduce((s, m) => s + m.totalHours, 0));
const memberCount = memberView.length;
const averageHours = memberCount > 0 ? roundHours(totalHours / memberCount) : 0;
const expectedTotalHours = memberCount * source.expectedHoursPerMember;
const fillRate = expectedTotalHours > 0 ? clampPercent((totalHours / expectedTotalHours) * 100) : 0;
const lowThreshold = averageHours * 0.8;
const highThreshold = source.expectedHoursPerMember * 1.125;
const lowCount = memberView.filter(m => m.totalHours < lowThreshold).length;
const highCount = memberView.filter(m => m.totalHours > highThreshold).length;
let lowest: { memberName: string; hours: number } | null = null;
let highest: { memberName: string; hours: number } | null = null;
for (const m of memberView) {
if (!lowest || m.totalHours < lowest.hours) lowest = { memberName: m.memberName, hours: m.totalHours };
if (!highest || m.totalHours > highest.hours) highest = { memberName: m.memberName, hours: m.totalHours };
}
return {
weekStart: source.weekStart,
weekLabel,
members: memberView,
categories,
seriesMatrix,
totalHours,
averageHours,
expectedTotalHours,
fillRate,
lowCount,
highCount,
lowest,
highest
};
}
// === 团队负载C13 · teamLoad ===
export type WorkbenchTeamLoadLevel = 'high' | 'mid' | 'normal';
export type WorkbenchTeamLoadItemKind = 'project' | 'personal';
export interface WorkbenchTeamLoadItemSource {
/** projectId 或 'personal' */
key: string;
label: string;
kind: WorkbenchTeamLoadItemKind;
/** 该项目/个人事项下,该成员进行中的任务/事项数(任务按"负责人/协办人"口径,单任务多人各算 1 条) */
count: number;
}
export interface WorkbenchTeamLoadMemberSource {
memberId: string;
memberName: string;
/** 进行中按项目 + 个人事项的拆分(合计即"进行中总数" */
items: WorkbenchTeamLoadItemSource[];
/** 今天 ≤ 计划结束 ≤ 今天+3 天 且未完成 */
dueSoon: number;
/** 计划结束 < 今天 且未完成 */
overdue: number;
}
export interface WorkbenchTeamLoadSource {
/** 数据快照所属周(与 C12 对齐口径) */
weekStart: string;
members: WorkbenchTeamLoadMemberSource[];
}
export interface WorkbenchTeamLoadSegment extends WorkbenchTeamLoadItemSource {
/** 段宽占"柱子内部"的百分比(段总和 = 100% */
widthPercent: number;
}
export interface WorkbenchTeamLoadMember extends Omit<WorkbenchTeamLoadMemberSource, 'items'> {
/** items 合计 */
inProgress: number;
/** dueSoon + overdue */
urgent: number;
level: WorkbenchTeamLoadLevel;
/** 项目段(去掉 count = 0 */
segments: WorkbenchTeamLoadSegment[];
/** 柱子总长占容器百分比0-100溢出时 = 100 */
barWidthPercent: number;
/** 溢出数量inProgress - scaleMax不溢出 = 0柱子末端显示 "+N" */
overflowExtra: number;
}
export interface WorkbenchTeamLoadView {
weekStart: string;
/** 已按 level → inProgress desc 排序,高负载置顶 */
members: WorkbenchTeamLoadMember[];
/** 柱子量程上限(固定值,避免某个成员极端值把全员压扁) */
scaleMax: number;
highCount: number;
midCount: number;
/** 临期+逾期总条数(团队聚合) */
urgentTotal: number;
}
const TEAM_LOAD_INPROGRESS_HIGH = 6;
const TEAM_LOAD_INPROGRESS_MID = 4;
const TEAM_LOAD_URGENT_HIGH = 2;
const TEAM_LOAD_URGENT_MID = 1;
/** 柱子量程上限:固定 10确保高负载阈值 6 在 60% 位置可辨;超出走 "+N" 文字 */
const TEAM_LOAD_SCALE_MAX = 10;
function resolveTeamLoadLevel(inProgress: number, urgent: number): WorkbenchTeamLoadLevel {
if (inProgress >= TEAM_LOAD_INPROGRESS_HIGH || urgent >= TEAM_LOAD_URGENT_HIGH) return 'high';
if (inProgress >= TEAM_LOAD_INPROGRESS_MID || urgent >= TEAM_LOAD_URGENT_MID) return 'mid';
return 'normal';
}
const LEVEL_RANK: Record<WorkbenchTeamLoadLevel, number> = { high: 0, mid: 1, normal: 2 };
export function buildWorkbenchTeamLoadView(source: WorkbenchTeamLoadSource): WorkbenchTeamLoadView {
const scaleMax = TEAM_LOAD_SCALE_MAX;
const enriched: WorkbenchTeamLoadMember[] = source.members.map(m => {
const inProgress = m.items.reduce((s, it) => s + it.count, 0);
const urgent = m.dueSoon + m.overdue;
const barWidthPercent = scaleMax > 0 ? Math.min(100, (inProgress / scaleMax) * 100) : 0;
const overflowExtra = Math.max(0, inProgress - scaleMax);
// 段宽永远相对柱子内部按 count/inProgress 算(段总和 = 100%
// 柱子总长靠 barWidthPercent 控制;不溢出时柱子未顶满,溢出时柱子顶满 + 末端 +N
const segments: WorkbenchTeamLoadSegment[] = m.items
.filter(it => it.count > 0)
.map(it => ({
...it,
widthPercent: inProgress > 0 ? (it.count / inProgress) * 100 : 0
}));
return {
memberId: m.memberId,
memberName: m.memberName,
dueSoon: m.dueSoon,
overdue: m.overdue,
inProgress,
urgent,
level: resolveTeamLoadLevel(inProgress, urgent),
segments,
barWidthPercent,
overflowExtra
};
});
enriched.sort((a, b) => {
if (LEVEL_RANK[a.level] !== LEVEL_RANK[b.level]) return LEVEL_RANK[a.level] - LEVEL_RANK[b.level];
if (a.inProgress !== b.inProgress) return b.inProgress - a.inProgress;
return b.urgent - a.urgent;
});
const highCount = enriched.filter(m => m.level === 'high').length;
const midCount = enriched.filter(m => m.level === 'mid').length;
const urgentTotal = enriched.reduce((s, m) => s + m.urgent, 0);
return {
weekStart: source.weekStart,
members: enriched,
scaleMax,
highCount,
midCount,
urgentTotal
};
}
export type WorkbenchHealthLevel = 'green' | 'yellow' | 'red';
@@ -502,26 +755,41 @@ export function buildWorkbenchProgressBars(source: readonly WorkbenchProgressBar
return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) }));
}
export type WorkbenchFavoriteKind = 'product' | 'project' | 'requirement' | 'task';
export interface WorkbenchFavoriteItemSource {
export interface WorkbenchMyExecutionItemSource {
id: string;
kind: WorkbenchFavoriteKind;
title: string;
source: string;
executionName: string;
/** 关联项目 */
projectId: string;
projectName: string;
/** 执行状态编码projectExecution 域pending / active / paused / completed / cancelled */
statusCode: string;
/** 状态名(后端字典返回) */
statusName: string;
/** 优先级编码(取 RDMS_REQ_PRIORITY_DICT_CODE 字典) */
priority: string;
/** 计划起止 */
plannedStartDate: string | null;
plannedEndDate: string | null;
/** 实际起止 */
actualStartDate: string | null;
actualEndDate: string | null;
/** 进度0-100 整数) */
progressRate: number;
/** 关联项目需求 ID可选 */
projectRequirementId?: string;
/** 关联项目需求名称(可选) */
projectRequirementName?: string;
}
export interface WorkbenchFavoriteItem extends WorkbenchFavoriteItemSource {
kindLabel: string;
kindTone: 'sky' | 'emerald' | 'amber' | 'rose';
}
export type WorkbenchMyExecutionItem = WorkbenchMyExecutionItemSource;
export function buildWorkbenchFavoriteItems(source: readonly WorkbenchFavoriteItemSource[]): WorkbenchFavoriteItem[] {
const meta: Record<WorkbenchFavoriteKind, { label: string; tone: 'sky' | 'emerald' | 'amber' | 'rose' }> = {
product: { label: '产品', tone: 'sky' },
project: { label: '项目', tone: 'emerald' },
requirement: { label: '需求', tone: 'amber' },
task: { label: '任务', tone: 'rose' }
};
return source.map(s => ({ ...s, kindLabel: meta[s.kind].label, kindTone: meta[s.kind].tone }));
/** 过滤掉已完成 / 已取消 / 进度满 100 的执行(默认不在工作台呈现) */
export function buildWorkbenchMyExecutionItems(
source: readonly WorkbenchMyExecutionItemSource[]
): WorkbenchMyExecutionItem[] {
return source.filter(item => {
if (item.statusCode === 'completed' || item.statusCode === 'cancelled') return false;
if (item.progressRate >= 100) return false;
return true;
});
}

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { ElMessageBox } from 'element-plus';
import { useWorkbenchStore } from '@/store/modules/workbench';
import { buildWorkbenchBannerSummary } from './homepage';
import { workbenchBannerSummaryMock } from './mock';
import {
type WorkbenchColumnId,
type WorkbenchModuleKey,
@@ -16,28 +14,14 @@ import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue';
import WorkbenchModuleLibrary from './modules/workbench-module-library.vue';
// 保留 6 个 + 重构 2 个key 沿用)
import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue';
import WorkbenchMyTask from './modules/workbench-my-task.vue';
import WorkbenchMyRequirement from './modules/workbench-my-requirement.vue';
import WorkbenchProjectGrid from './modules/workbench-project-grid.vue';
import WorkbenchShortcut from './modules/workbench-shortcut.vue';
import WorkbenchProjectHealth from './modules/workbench-project-health.vue';
import WorkbenchTeamTodo from './modules/workbench-team-todo.vue';
import WorkbenchFavorite from './modules/workbench-favorite.vue';
// 新增 15 个(蓝图 2026-05-22原 A3 myTicket 已废弃)
import WorkbenchMentions from './modules/workbench-mentions.vue';
import WorkbenchApproval from './modules/workbench-approval.vue';
import WorkbenchWorklogReminder from './modules/workbench-worklog-reminder.vue';
// 新增 10 个(蓝图 2026-05-22原 A3 myTicket、A4 mentions、A5 approval、A6 worklogReminder、B10 personalItem、F23 projectSnapshot 已废弃)
import WorkbenchMyExecution from './modules/workbench-my-execution.vue';
import WorkbenchPersonalItem from './modules/workbench-personal-item.vue';
import WorkbenchProjectSnapshot from './modules/workbench-project-snapshot.vue';
import WorkbenchProductSnapshot from './modules/workbench-product-snapshot.vue';
import WorkbenchTeamWorklog from './modules/workbench-team-worklog.vue';
import WorkbenchTeamLoad from './modules/workbench-team-load.vue';
import WorkbenchRiskAlert from './modules/workbench-risk-alert.vue';
import WorkbenchMyWeekWorklog from './modules/workbench-my-week-worklog.vue';
import WorkbenchMyCompletionRate from './modules/workbench-my-completion-rate.vue';
import WorkbenchTicketSla from './modules/workbench-ticket-sla.vue';
import WorkbenchRecentVisit from './modules/workbench-recent-visit.vue';
import WorkbenchNoticeNotification from './modules/workbench-notice-notification.vue';
defineOptions({ name: 'Workbench' });
@@ -45,33 +29,22 @@ defineOptions({ name: 'Workbench' });
const { registerModuleComponent } = useWorkbenchModules();
// 保留 6 个 + 重构 2 个
registerModuleComponent('myTodo', WorkbenchTodoPanel);
registerModuleComponent('myTask', WorkbenchMyTask);
registerModuleComponent('myRequirement', WorkbenchMyRequirement);
registerModuleComponent('myProject', WorkbenchProjectGrid);
registerModuleComponent('shortcut', WorkbenchShortcut);
registerModuleComponent('projectHealth', WorkbenchProjectHealth);
registerModuleComponent('teamTodo', WorkbenchTeamTodo);
registerModuleComponent('favorite', WorkbenchFavorite);
// 新增 15 个
registerModuleComponent('mentions', WorkbenchMentions);
registerModuleComponent('approval', WorkbenchApproval);
registerModuleComponent('worklogReminder', WorkbenchWorklogReminder);
// 新增 10 个
registerModuleComponent('myExecution', WorkbenchMyExecution);
registerModuleComponent('personalItem', WorkbenchPersonalItem);
registerModuleComponent('projectSnapshot', WorkbenchProjectSnapshot);
registerModuleComponent('productSnapshot', WorkbenchProductSnapshot);
registerModuleComponent('teamWorklog', WorkbenchTeamWorklog);
registerModuleComponent('teamLoad', WorkbenchTeamLoad);
registerModuleComponent('riskAlert', WorkbenchRiskAlert);
registerModuleComponent('myWeekWorklog', WorkbenchMyWeekWorklog);
registerModuleComponent('myCompletionRate', WorkbenchMyCompletionRate);
registerModuleComponent('ticketSla', WorkbenchTicketSla);
registerModuleComponent('recentVisit', WorkbenchRecentVisit);
registerModuleComponent('noticeNotification', WorkbenchNoticeNotification);
const workbench = useWorkbenchStore();
const libraryOpen = ref(false);
// 暴露给 workbench-module-card 内的"编辑布局"按钮,避免每个 widget 都透传 emit
provide('workbenchEnterEditing', () => workbench.enterEditing());
onMounted(() => {
workbench.load();
});
@@ -92,8 +65,6 @@ watch(
}
);
const bannerSummary = computed(() => buildWorkbenchBannerSummary(workbenchBannerSummaryMock));
function onColumnUpdate(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) {
workbench.setColumnModules(columnId, modules);
}
@@ -124,18 +95,7 @@ onBeforeRouteLeave(async (_to, _from, next) => {
<template>
<div class="workbench">
<WorkbenchBanner :summary="bannerSummary" />
<div class="workbench__toolbar">
<ElButton v-if="workbench.mode === 'normal'" type="primary" link @click="workbench.enterEditing">
<SvgIcon icon="mdi:pencil-outline" />
自定义布局
</ElButton>
<ElButton v-else type="primary" link @click="libraryOpen = true">
<SvgIcon icon="mdi:view-grid-plus-outline" />
模块库
</ElButton>
</div>
<WorkbenchBanner />
<WorkbenchEditOverlay
v-if="workbench.mode === 'editing'"
@@ -144,6 +104,7 @@ onBeforeRouteLeave(async (_to, _from, next) => {
@save="workbench.saveEditing"
@cancel="workbench.cancelEditing"
@reset="handleReset"
@open-library="libraryOpen = true"
/>
<ElEmpty v-if="workbench.layout.columns.every(c => c.modules.length === 0)" description="还没有可见模块">
@@ -183,10 +144,6 @@ onBeforeRouteLeave(async (_to, _from, next) => {
flex-direction: column;
gap: 16px;
}
.workbench__toolbar {
display: flex;
justify-content: flex-end;
}
.workbench__main {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);

View File

@@ -1,30 +1,21 @@
import dayjs from 'dayjs';
import type {
WorkbenchActivityItemSource,
WorkbenchBannerSummarySource,
WorkbenchFavoriteItemSource,
WorkbenchKpiSource,
WorkbenchMyRequirementGroupSource,
WorkbenchMyTaskItemSource,
WorkbenchMyExecutionItemSource,
WorkbenchMyWeekWorklogSource,
WorkbenchOwnedProjectItemSource,
WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource,
WorkbenchProjectItemSource,
WorkbenchTeamTodoRowSource,
WorkbenchTeamLoadSource,
WorkbenchTeamWorklogSource,
WorkbenchTodoItemSource
} from './homepage';
const now = dayjs();
const iso = (date: dayjs.Dayjs) => date.format('YYYY-MM-DD HH:mm:ss');
export const workbenchBannerSummaryMock = {
todoCount: 8,
upcomingCount: 2,
weekDone: 12,
weekTotal: 18,
weekInProgress: 5,
weekOverdue: 1
} satisfies WorkbenchBannerSummarySource;
export const workbenchKpiMock = {
todo: { count: 8, diffFromYesterday: 1 },
task: { count: 14, diffFromYesterday: -1 },
@@ -57,13 +48,13 @@ export const workbenchTodoMock = [
},
{
id: 'todo-3',
category: 'review',
title: '订单导出 V2 需求评审',
createdTime: iso(now.subtract(5, 'day').hour(14).minute(40)),
deadline: iso(now.add(3, 'day').hour(18).minute(0)),
source: '需求 · 收银台 V3',
priority: 'high',
routeKey: 'product_list'
category: 'approval',
title: '李四 · 第 21 周周报待审批',
createdTime: iso(now.subtract(2, 'day').hour(18).minute(20)),
deadline: null,
source: '周报 · 产品组',
priority: 'mid',
routeKey: 'workbench'
},
{
id: 'todo-4',
@@ -117,13 +108,13 @@ export const workbenchTodoMock = [
},
{
id: 'todo-9',
category: 'review',
title: 'API 返回结构调整评审',
category: 'approval',
title: '张三 · 5 月加班 12h 申请待审批',
createdTime: iso(now.subtract(1, 'day').hour(17).minute(0)),
deadline: iso(now.add(12, 'day').hour(18).minute(0)),
source: '需求 · 收银台 V3',
deadline: null,
source: '加班申请 · 研发组',
priority: 'mid',
routeKey: 'product_list'
routeKey: 'workbench'
},
{
id: 'todo-10',
@@ -217,6 +208,209 @@ export const workbenchActivityMock = [
}
] satisfies WorkbenchActivityItemSource[];
export const workbenchMyExecutionMock = [
// 商城 V2 升级 · 3 条(分组测试主项目)
{
id: 'exec-1',
executionName: '迭代 24.05 · 后端联调',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'active',
statusName: '进行中',
priority: '1',
plannedStartDate: iso(now.subtract(10, 'day').startOf('day')),
plannedEndDate: iso(now.add(3, 'day').endOf('day')),
actualStartDate: iso(now.subtract(8, 'day').startOf('day')),
actualEndDate: null,
progressRate: 68,
projectRequirementId: 'req-mall-001',
projectRequirementName: '订单履约后端拆分(一期)'
},
{
id: 'exec-2',
executionName: '会员等级提示文案',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'active',
statusName: '进行中',
priority: '3',
plannedStartDate: iso(now.subtract(4, 'day').startOf('day')),
plannedEndDate: iso(now.add(6, 'day').endOf('day')),
actualStartDate: iso(now.subtract(3, 'day').startOf('day')),
actualEndDate: null,
progressRate: 25,
projectRequirementId: 'req-mall-002',
projectRequirementName: '会员等级 UI 升级'
},
{
id: 'exec-3',
executionName: '订单退款流程拆分',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'paused',
statusName: '已暂停',
priority: '2',
plannedStartDate: iso(now.subtract(20, 'day').startOf('day')),
plannedEndDate: iso(now.add(10, 'day').endOf('day')),
actualStartDate: iso(now.subtract(15, 'day').startOf('day')),
actualEndDate: null,
progressRate: 50
},
// 风控引擎 · 2 条(含一条计划已过期)
{
id: 'exec-4',
executionName: '关键路径优化',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'active',
statusName: '进行中',
priority: '1',
plannedStartDate: iso(now.subtract(20, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(2, 'day').endOf('day')),
actualStartDate: iso(now.subtract(18, 'day').startOf('day')),
actualEndDate: null,
progressRate: 42,
projectRequirementId: 'req-risk-001',
projectRequirementName: '风控决策链路压缩'
},
{
id: 'exec-5',
executionName: '黑名单规则改造',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'pending',
statusName: '待开始',
priority: '3',
plannedStartDate: iso(now.add(5, 'day').startOf('day')),
plannedEndDate: iso(now.add(20, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 0
},
// 收银台 V3 · 1 条
{
id: 'exec-6',
executionName: '多币种支持 · 计算引擎',
projectId: 'prj-cashier',
projectName: '收银台 V3',
statusCode: 'pending',
statusName: '待开始',
priority: '2',
plannedStartDate: iso(now.add(2, 'day').startOf('day')),
plannedEndDate: iso(now.add(15, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 0,
projectRequirementId: 'req-cashier-001',
projectRequirementName: '多币种结算(含汇率快照)'
},
// 订单中心 · 1 条
{
id: 'exec-7',
executionName: '订单导出 V2',
projectId: 'prj-order',
projectName: '订单中心',
statusCode: 'active',
statusName: '进行中',
priority: '4',
plannedStartDate: iso(now.subtract(15, 'day').startOf('day')),
plannedEndDate: iso(now.add(7, 'day').endOf('day')),
actualStartDate: iso(now.subtract(12, 'day').startOf('day')),
actualEndDate: null,
progressRate: 35
},
// 已完成 —— builder 应过滤掉
{
id: 'exec-8',
executionName: '上一迭代 · 前端联调',
projectId: 'prj-mall-v2',
projectName: '商城 V2 升级',
statusCode: 'completed',
statusName: '已完成',
priority: '3',
plannedStartDate: iso(now.subtract(40, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(15, 'day').endOf('day')),
actualStartDate: iso(now.subtract(38, 'day').startOf('day')),
actualEndDate: iso(now.subtract(14, 'day').endOf('day')),
progressRate: 100
},
// 已取消 —— builder 应过滤掉
{
id: 'exec-9',
executionName: '促销活动 · 春节专题',
projectId: 'prj-marketing',
projectName: '营销中台',
statusCode: 'cancelled',
statusName: '已取消',
priority: '3',
plannedStartDate: iso(now.subtract(30, 'day').startOf('day')),
plannedEndDate: iso(now.subtract(10, 'day').endOf('day')),
actualStartDate: null,
actualEndDate: null,
progressRate: 15
},
// 进度 100 但状态未扭转 —— builder 应过滤掉
{
id: 'exec-10',
executionName: '风控规则升级(待扭转)',
projectId: 'prj-risk',
projectName: '风控引擎',
statusCode: 'active',
statusName: '进行中',
priority: '2',
plannedStartDate: iso(now.subtract(8, 'day').startOf('day')),
plannedEndDate: iso(now.add(1, 'day').endOf('day')),
actualStartDate: iso(now.subtract(6, 'day').startOf('day')),
actualEndDate: null,
progressRate: 100
}
] satisfies WorkbenchMyExecutionItemSource[];
export const workbenchOwnedProjectMock = [
{
id: 'p1',
name: '商城 V2 升级',
code: 'MALL-V2',
progress: 70,
executionCount: 5,
taskCount: 32,
memberCount: 6,
overdueCount: 1,
remainingDays: 12,
myRole: '项目负责人',
milestones: [
{ id: 'm1', title: 'SSO 改造提测', timeLabel: '今日 18:00', tone: 'amber' },
{ id: 'm2', title: '迭代 24.05 关闭', timeLabel: '今日', tone: 'amber' },
{ id: 'm3', title: '多币种支持评审', timeLabel: '05-26', tone: 'slate' }
],
members: [
{ name: '张三', load: 50, level: 'ok' },
{ name: '李四', load: 30, level: 'ok' },
{ name: '王五', load: 90, level: 'over' }
]
},
{
id: 'p2',
name: '风控引擎接入',
code: 'RISK-ENGINE',
progress: 45,
executionCount: 3,
taskCount: 18,
memberCount: 4,
overdueCount: 2,
remainingDays: 30,
myRole: '项目负责人',
milestones: [
{ id: 'm4', title: '分片设计评审', timeLabel: '明日', tone: 'amber' },
{ id: 'm5', title: '缓存穿透优化交付', timeLabel: '05-28', tone: 'slate' }
],
members: [
{ name: '李四', load: 30, level: 'ok' },
{ name: '钱七', load: 65, level: 'warn' }
]
}
] satisfies WorkbenchOwnedProjectItemSource[];
export const workbenchProjectMock = [
{
id: 'prj-1',
@@ -253,104 +447,218 @@ export const workbenchProjectMock = [
}
] satisfies WorkbenchProjectItemSource[];
export const workbenchMyTaskMock = [
{
id: 't-1',
title: '支付回调接口联调',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '收银台 V3 · 后端联调',
projectName: '收银台 V3',
priority: 'high',
deadline: iso(now.add(1, 'day').hour(17))
},
{
id: 't-2',
title: '订单导出 V2 文档编写',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '订单中心 · 文档',
projectName: '订单中心',
priority: 'mid',
deadline: iso(now.add(3, 'day').hour(12))
},
{
id: 't-3',
title: 'API 返回结构调整',
statusCode: 'pending',
statusLabel: '待开始',
executionName: '收银台 V3 · 后端联调',
projectName: '收银台 V3',
priority: 'mid',
deadline: iso(now.subtract(1, 'day').hour(18))
},
{
id: 't-4',
title: '会员等级文案校对',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '会员中心 · 文案',
projectName: '会员中心',
priority: 'low',
deadline: iso(now.add(2, 'day').hour(15))
},
{
id: 't-5',
title: '收银台 H5 适配',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '收银台 V3 · 前端',
projectName: '收银台 V3',
priority: 'high',
deadline: iso(now.hour(20))
}
] satisfies WorkbenchMyTaskItemSource[];
const currentWeekStart = now.startOf('isoWeek').format('YYYY-MM-DD');
const previousWeekStart = now.subtract(1, 'week').startOf('isoWeek').format('YYYY-MM-DD');
export const workbenchMyRequirementMock = [
{ statusCode: 'pendingReview', statusLabel: '待评审', count: 3, tone: 'amber' },
{ statusCode: 'reviewing', statusLabel: '评审中', count: 2, tone: 'sky' },
{ statusCode: 'developing', statusLabel: '开发中', count: 5, tone: 'emerald' },
{ statusCode: 'paused', statusLabel: '已暂停', count: 1, tone: 'rose' }
] satisfies WorkbenchMyRequirementGroupSource[];
export const workbenchTeamTodoMock = [
{
projectId: 'prj-1',
projectName: '收银台 V3',
memberId: 'm-1',
memberName: '张三',
inProgress: 5,
overdue: 2,
weekDone: 3
export const workbenchMyWeekWorklogMock = {
current: {
weekStart: currentWeekStart,
weeklyFilledHours: 5,
dailyHours: [4, 7, 6, 8, 7.5],
target: 40,
distribution: [
{ key: 'prj-mall-v2', label: '商城 V2 升级', hours: 12.5, kind: 'project', projectId: 'prj-mall-v2' },
{ key: 'prj-risk', label: '风控引擎接入', hours: 8, kind: 'project', projectId: 'prj-risk' },
{ key: 'prj-cashier', label: '收银台 V3', hours: 6, kind: 'project', projectId: 'prj-cashier' },
{ key: 'prj-order', label: '订单中心', hours: 5, kind: 'project', projectId: 'prj-order' },
{ key: 'prj-member', label: '会员中心', hours: 3, kind: 'project', projectId: 'prj-member' },
{ key: 'prj-marketing', label: '营销中台', hours: 2, kind: 'project', projectId: 'prj-marketing' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
},
{
projectId: 'prj-1',
projectName: '收银台 V3',
memberId: 'm-2',
memberName: '李四',
inProgress: 3,
overdue: 0,
weekDone: 4
},
{
projectId: 'prj-2',
projectName: '会员中心',
memberId: 'm-3',
memberName: '王五',
inProgress: 2,
overdue: 1,
weekDone: 2
},
{
projectId: 'prj-3',
projectName: '订单中心',
memberId: 'm-4',
memberName: '赵六',
inProgress: 4,
overdue: 0,
weekDone: 5
previous: {
weekStart: previousWeekStart,
weeklyFilledHours: 0,
dailyHours: [8, 8, 7, 8, 7],
target: 40,
distribution: [
{ key: 'prj-mall-v2', label: '商城 V2 升级', hours: 15, kind: 'project', projectId: 'prj-mall-v2' },
{ key: 'prj-risk', label: '风控引擎接入', hours: 9, kind: 'project', projectId: 'prj-risk' },
{ key: 'prj-cashier', label: '收银台 V3', hours: 7, kind: 'project', projectId: 'prj-cashier' },
{ key: 'personal', label: '个人事项', hours: 5, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
}
] satisfies WorkbenchTeamTodoRowSource[];
} satisfies WorkbenchMyWeekWorklogSource;
// 团队工时口径约定:「团队 = 当前用户所在团队,含自己」。
// 后端 /workbench/team-worklog 接口返回的 members 必须包含当前用户自己——
// 这样没有下级的人普通员工切到「团队工时」tab 也至少有自己这一条数据,
// 不会出现空白态。约定 members[0] = 当前用户UI 用「(我)」后缀标识。
export const workbenchTeamWorklogMock = {
current: {
weekStart: currentWeekStart,
expectedHoursPerMember: 40,
members: [
{
memberId: 'u-1',
memberName: '张三(我)',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 22, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 10, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
},
{
memberId: 'u-2',
memberName: '李四',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 18, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 20, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 3, kind: 'personal' },
{ key: 'other', label: '其他', hours: 1, kind: 'other' }
]
},
{
memberId: 'u-3',
memberName: '王五',
items: [
{ key: 'prj-member', label: '会员中心', hours: 14, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 12, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 2, kind: 'personal' },
{ key: 'other', label: '其他', hours: 2, kind: 'other' }
]
},
{
memberId: 'u-4',
memberName: '赵六',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 24, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 18, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 4, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 2, kind: 'personal' }
]
},
{
memberId: 'u-5',
memberName: '钱七',
items: [
{ key: 'prj-order', label: '订单中心', hours: 14, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 6, kind: 'personal' },
{ key: 'other', label: '其他', hours: 5, kind: 'other' }
]
}
]
},
previous: {
weekStart: previousWeekStart,
expectedHoursPerMember: 40,
members: [
{
memberId: 'u-1',
memberName: '张三(我)',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 26, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 8, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 3, kind: 'personal' },
{ key: 'other', label: '其他', hours: 3, kind: 'other' }
]
},
{
memberId: 'u-2',
memberName: '李四',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 15, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 22, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' }
]
},
{
memberId: 'u-3',
memberName: '王五',
items: [
{ key: 'prj-member', label: '会员中心', hours: 16, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 10, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 5, kind: 'personal' },
{ key: 'other', label: '其他', hours: 3, kind: 'other' }
]
},
{
memberId: 'u-4',
memberName: '赵六',
items: [
{ key: 'prj-cashier', label: '收银台 V3', hours: 20, kind: 'project' },
{ key: 'prj-order', label: '订单中心', hours: 16, kind: 'project' },
{ key: 'prj-member', label: '会员中心', hours: 6, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 3, kind: 'personal' }
]
},
{
memberId: 'u-5',
memberName: '钱七',
items: [
{ key: 'prj-order', label: '订单中心', hours: 16, kind: 'project' },
{ key: 'personal', label: '个人事项', hours: 4, kind: 'personal' },
{ key: 'other', label: '其他', hours: 4, kind: 'other' }
]
}
]
}
} satisfies { current: WorkbenchTeamWorklogSource; previous: WorkbenchTeamWorklogSource };
// 项目 key 与 workbenchTeamWorklogMock 对齐,保证跨 widget 项目色一致
export const workbenchTeamLoadMock = {
weekStart: now.startOf('isoWeek').format('YYYY-MM-DD'),
members: [
// 高负载:进行中 7 或 临期+逾期 ≥ 2
{
memberId: 'u-1',
memberName: '张三',
items: [
{ key: 'prj-cashier', label: '收银台 V3', kind: 'project', count: 4 },
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 2 },
{ key: 'personal', label: '个人事项', kind: 'personal', count: 1 }
],
dueSoon: 2,
overdue: 1
},
{
memberId: 'u-4',
memberName: '赵六',
items: [
{ key: 'prj-cashier', label: '收银台 V3', kind: 'project', count: 2 },
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 2 },
{ key: 'prj-member', label: '会员中心', kind: 'project', count: 1 }
],
dueSoon: 1,
overdue: 2
},
// 中负载:进行中 ≥ 4 或 临期+逾期 ≥ 1
{
memberId: 'u-2',
memberName: '李四',
items: [
{ key: 'prj-cashier', label: '收银台 V3', kind: 'project', count: 2 },
{ key: 'prj-member', label: '会员中心', kind: 'project', count: 2 }
],
dueSoon: 1,
overdue: 0
},
{
memberId: 'u-3',
memberName: '王五',
items: [
{ key: 'prj-member', label: '会员中心', kind: 'project', count: 2 },
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 1 }
],
dueSoon: 1,
overdue: 0
},
// 正常
{
memberId: 'u-5',
memberName: '钱七',
items: [
{ key: 'prj-order', label: '订单中心', kind: 'project', count: 1 },
{ key: 'personal', label: '个人事项', kind: 'personal', count: 1 }
],
dueSoon: 0,
overdue: 0
}
]
} satisfies WorkbenchTeamLoadSource;
export const workbenchProjectHealthMock = [
{
@@ -387,10 +695,3 @@ export const workbenchProgressChartMock = [
{ projectId: 'prj-2', projectName: '会员中心', weekCompletionRate: 62 },
{ projectId: 'prj-3', projectName: '订单中心', weekCompletionRate: 45 }
] satisfies WorkbenchProgressBarSource[];
export const workbenchFavoriteMock = [
{ id: 'fav-1', kind: 'product', title: '收银台 V3', source: '产品库' },
{ id: 'fav-2', kind: 'project', title: '会员中心 · 一期', source: '项目库' },
{ id: 'fav-3', kind: 'requirement', title: '订单导出 V2', source: '收银台 V3' },
{ id: 'fav-4', kind: 'task', title: '支付回调接口联调', source: '收银台 V3 · 后端联调' }
] satisfies WorkbenchFavoriteItemSource[];

View File

@@ -1,94 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchApproval' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ApprovalItem {
id: string;
title: string;
meta: string;
}
const items: ApprovalItem[] = [
{ id: 'a1', title: '赵六 申请 加入「风控引擎」项目', meta: '作为 开发成员 · 10min 前' },
{ id: 'a2', title: '钱七 申请 关闭执行「迭代 24.05」', meta: '含 2 个未完成任务 · 1h 前' },
{ id: 'a3', title: '孙八 申请 延期 3 天', meta: '任务「分片设计」 · 昨日' }
];
function approve(id: string) {
window.$message?.success(`已批准 ${id}mock`);
}
function reject(id: string) {
window.$message?.warning(`已驳回 ${id}mock`);
}
</script>
<template>
<WorkbenchModuleCard
title="待审批"
icon="mdi:checkbox-multiple-marked-outline"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="approval-list">
<li v-for="item in items" :key="item.id" class="approval-item">
<div class="approval-body">
<div class="approval-title">{{ item.title }}</div>
<div class="approval-meta">{{ item.meta }}</div>
</div>
<div class="approval-actions">
<ElButton size="small" type="success" plain @click="approve(item.id)">批准</ElButton>
<ElButton size="small" type="danger" plain @click="reject(item.id)">驳回</ElButton>
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.approval-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.approval-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.approval-body {
flex: 1;
min-width: 0;
}
.approval-title {
font-size: 13px;
font-weight: 500;
}
.approval-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.approval-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
</style>

View File

@@ -1,273 +1,302 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import dayjs from 'dayjs';
import { useAuthStore } from '@/store/modules/auth';
import { getGreeting, getTodayLabel } from '../homepage';
import type { WorkbenchBannerSummary } from '../homepage';
import { getGreeting } from '../homepage';
defineOptions({ name: 'WorkbenchBanner' });
interface Props {
summary: WorkbenchBannerSummary;
interface NoticeRow {
id: string;
title: string;
timeLabel: string;
}
const props = defineProps<Props>();
const authStore = useAuthStore();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '同学');
const greeting = computed(() => getGreeting());
const todayLabel = computed(() => getTodayLabel());
const rhythmItems = computed(() => [
{ label: '本周完成', value: `${props.summary.weekDone} / ${props.summary.weekTotal}`, tone: 'emerald' as const },
{ label: '进行中', value: String(props.summary.weekInProgress), tone: 'sky' as const },
{ label: '逾期', value: String(props.summary.weekOverdue), tone: 'rose' as const }
]);
const weekdayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const dateContext = computed(() => {
const now = dayjs();
return {
date: now.format('YYYY-MM-DD'),
weekday: weekdayNames[now.isoWeekday()] ?? '',
week: `${now.isoWeek()}`
};
});
// 公告 mockbanner 阶段本地维护,等公告中心接口落地再迁移至 mock.ts
const allNotices: NoticeRow[] = [
{ id: 'n1', title: '【运维】本周六 02:00-04:00 数据库主从切换', timeLabel: '2 天前' },
{ id: 'n2', title: '【HR】Q2 OKR 复盘截止 06-05', timeLabel: '3 天前' },
{ id: 'n3', title: '【流程】工单 SLA 新规则即将上线', timeLabel: '1 周前' },
{ id: 'n4', title: '【系统】新版本 25.06 发布日程公告', timeLabel: '2 周前' },
{ id: 'n5', title: '【行政】6 月端午节放假安排', timeLabel: '3 周前' },
{ id: 'n6', title: '【安全】禁止使用未受控外部 AI 工具处理客户数据', timeLabel: '1 个月前' }
];
const previewNotices = computed(() => allNotices.slice(0, 3));
const drawerOpen = ref(false);
function openDrawer() {
drawerOpen.value = true;
}
function closeDrawer() {
drawerOpen.value = false;
}
</script>
<template>
<section class="workbench-banner">
<div class="workbench-banner__identity">
<div class="workbench-banner__title-group">
<h1 class="workbench-banner__title">{{ greeting }}{{ displayName }}</h1>
</div>
<p class="workbench-banner__subtitle">{{ todayLabel }}</p>
<div class="workbench-banner__digest">
<div class="workbench-banner__digest-item">
<span class="workbench-banner__digest-label">今日待办</span>
<strong class="workbench-banner__digest-value">{{ summary.todoCount }}</strong>
<span class="workbench-banner__digest-unit"></span>
</div>
<span class="workbench-banner__digest-sep">·</span>
<div class="workbench-banner__digest-item">
<span class="workbench-banner__digest-label">即将到期</span>
<strong class="workbench-banner__digest-value workbench-banner__digest-value--warn">
{{ summary.upcomingCount }}
</strong>
<span class="workbench-banner__digest-unit"></span>
</div>
</div>
<h1 class="workbench-banner__title">{{ greeting }}{{ displayName }}</h1>
<p class="workbench-banner__meta">
{{ dateContext.date }} {{ dateContext.weekday }}
<span class="workbench-banner__meta-dot">·</span>
{{ dateContext.week }}
</p>
</div>
<div class="workbench-banner__rhythm">
<div class="workbench-banner__rhythm-header">
<h2 class="workbench-banner__rhythm-title">本周节奏</h2>
<span class="workbench-banner__rhythm-rate">完成率 {{ summary.weekCompletionRate }}%</span>
</div>
<div class="workbench-banner__rhythm-bar">
<div class="workbench-banner__rhythm-bar-inner" :style="{ width: `${summary.weekCompletionRate}%` }" />
</div>
<ul class="workbench-banner__rhythm-list">
<li
v-for="item in rhythmItems"
:key="item.label"
class="workbench-banner__rhythm-item"
:class="`workbench-banner__rhythm-item--${item.tone}`"
>
<span class="workbench-banner__rhythm-item-label">{{ item.label }}</span>
<strong class="workbench-banner__rhythm-item-value">{{ item.value }}</strong>
<div class="workbench-banner__notice">
<header class="workbench-banner__notice-header">
<span class="workbench-banner__notice-title">
<SvgIcon icon="mdi:bullhorn-outline" class="workbench-banner__notice-icon" />
公告
<span class="workbench-banner__notice-count">{{ allNotices.length }}</span>
</span>
<button class="workbench-banner__notice-more" type="button" @click="openDrawer">
更多
<SvgIcon icon="mdi:arrow-right" />
</button>
</header>
<ul class="workbench-banner__notice-list">
<li v-for="row in previewNotices" :key="row.id" class="workbench-banner__notice-row">
<span class="workbench-banner__notice-row-title">{{ row.title }}</span>
<span class="workbench-banner__notice-row-time">{{ row.timeLabel }}</span>
</li>
</ul>
</div>
<ElDrawer v-model="drawerOpen" title="全部公告" size="480px">
<ElScrollbar>
<ul class="workbench-banner__drawer-list">
<li v-for="row in allNotices" :key="row.id" class="workbench-banner__drawer-row">
<div class="workbench-banner__drawer-row-title">{{ row.title }}</div>
<div class="workbench-banner__drawer-row-time">{{ row.timeLabel }}</div>
</li>
</ul>
</ElScrollbar>
<template #footer>
<ElButton @click="closeDrawer">关闭</ElButton>
</template>
</ElDrawer>
</section>
</template>
<style scoped>
.workbench-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(280px, 1fr);
gap: 16px;
padding: 24px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
align-items: stretch;
gap: 24px;
padding: 22px 28px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 20px;
background-color: rgb(255 255 255 / 96%);
}
.workbench-banner__identity {
display: flex;
flex-direction: column;
gap: 14px;
justify-content: center;
gap: 8px;
min-width: 0;
}
.workbench-banner__title-group {
display: flex;
align-items: baseline;
gap: 14px;
flex-wrap: wrap;
}
.workbench-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 32px;
line-height: 1.15;
letter-spacing: -0.02em;
font-size: 26px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
}
.workbench-banner__subtitle {
.workbench-banner__meta {
margin: 0;
color: rgb(100 116 139 / 92%);
font-size: 14px;
}
.workbench-banner__digest {
display: flex;
align-items: baseline;
gap: 12px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.workbench-banner__digest-item {
display: flex;
align-items: baseline;
gap: 8px;
}
.workbench-banner__digest-label {
color: rgb(100 116 139 / 92%);
font-size: 13px;
}
.workbench-banner__digest-value {
color: rgb(15 23 42 / 98%);
font-size: 24px;
line-height: 1;
}
.workbench-banner__digest-value--warn {
color: rgb(217 119 6 / 94%);
}
.workbench-banner__digest-unit {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.workbench-banner__digest-sep {
.workbench-banner__meta-dot {
margin: 0 6px;
color: rgb(203 213 225 / 96%);
font-size: 18px;
user-select: none;
}
.workbench-banner__rhythm {
.workbench-banner__notice {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 20px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(241 245 249 / 98%));
gap: 10px;
min-width: 0;
padding-left: 24px;
border-left: 1px solid rgb(226 232 240 / 88%);
}
.workbench-banner__rhythm-header {
.workbench-banner__notice-header {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.workbench-banner__rhythm-title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 15px;
font-weight: 700;
.workbench-banner__notice-title {
display: inline-flex;
align-items: center;
gap: 6px;
color: rgb(15 23 42 / 96%);
font-size: 14px;
font-weight: 600;
}
.workbench-banner__rhythm-rate {
color: rgb(5 150 105 / 94%);
.workbench-banner__notice-icon {
color: rgb(14 116 144 / 92%);
font-size: 16px;
}
.workbench-banner__notice-count {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 999px;
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 96%);
font-size: 11px;
font-weight: 500;
}
.workbench-banner__notice-more {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 6px;
border: none;
border-radius: 6px;
background-color: transparent;
color: rgb(14 116 144 / 96%);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease;
}
.workbench-banner__rhythm-bar {
position: relative;
height: 8px;
border-radius: 999px;
background-color: rgb(226 232 240 / 80%);
overflow: hidden;
.workbench-banner__notice-more:hover {
background-color: rgb(236 254 255 / 92%);
color: rgb(8 90 110 / 96%);
}
.workbench-banner__rhythm-bar-inner {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgb(14 116 144 / 92%), rgb(16 185 129 / 88%));
transition: width 240ms ease;
.workbench-banner__notice-more:focus-visible {
outline: 2px solid rgb(14 116 144 / 60%);
outline-offset: 2px;
}
.workbench-banner__rhythm-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
.workbench-banner__notice-list {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
max-height: 108px;
overflow-y: auto;
}
.workbench-banner__rhythm-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 12px;
border-radius: 14px;
background-color: rgb(248 250 252 / 96%);
.workbench-banner__notice-list::-webkit-scrollbar {
width: 6px;
}
.workbench-banner__rhythm-item--emerald {
background-color: rgb(236 253 245 / 88%);
.workbench-banner__notice-list::-webkit-scrollbar-thumb {
background-color: rgb(203 213 225 / 70%);
border-radius: 3px;
}
.workbench-banner__rhythm-item--sky {
background-color: rgb(240 249 255 / 88%);
.workbench-banner__notice-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed rgb(226 232 240 / 70%);
}
.workbench-banner__rhythm-item--rose {
background-color: rgb(255 241 242 / 88%);
.workbench-banner__notice-row:last-child {
border-bottom: none;
}
.workbench-banner__rhythm-item-label {
.workbench-banner__notice-row-title {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgb(30 41 59 / 96%);
font-size: 13px;
}
.workbench-banner__notice-row-time {
color: rgb(100 116 139 / 92%);
font-size: 12px;
white-space: nowrap;
}
.workbench-banner__drawer-list {
margin: 0;
padding: 0 4px 0 0;
list-style: none;
}
.workbench-banner__drawer-row {
padding: 12px 0;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-banner__drawer-row:last-child {
border-bottom: none;
}
.workbench-banner__drawer-row-title {
color: rgb(15 23 42 / 96%);
font-size: 14px;
line-height: 1.5;
}
.workbench-banner__drawer-row-time {
margin-top: 4px;
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.workbench-banner__rhythm-item-value {
color: rgb(15 23 42 / 98%);
font-size: 20px;
line-height: 1.1;
}
.workbench-banner__rhythm-item--emerald .workbench-banner__rhythm-item-value {
color: rgb(5 150 105 / 96%);
}
.workbench-banner__rhythm-item--sky .workbench-banner__rhythm-item-value {
color: rgb(14 116 144 / 96%);
}
.workbench-banner__rhythm-item--rose .workbench-banner__rhythm-item-value {
color: rgb(225 29 72 / 94%);
}
@media (width <= 1280px) {
@media (width <= 1024px) {
.workbench-banner {
grid-template-columns: 1fr;
}
.workbench-banner__notice {
padding-left: 0;
padding-top: 16px;
border-left: none;
border-top: 1px solid rgb(226 232 240 / 88%);
}
}
@media (width <= 768px) {
.workbench-banner {
padding: 18px;
padding: 18px 20px;
}
.workbench-banner__title {
font-size: 26px;
font-size: 22px;
}
}
</style>

View File

@@ -8,6 +8,7 @@ const emit = defineEmits<{
(e: 'save'): void;
(e: 'cancel'): void;
(e: 'reset'): void;
(e: 'open-library'): void;
}>();
</script>
@@ -18,6 +19,10 @@ const emit = defineEmits<{
正在编辑布局拖动模块换位 / 抽屉里把隐藏模块拖回来
</span>
<div class="edit-overlay__actions">
<ElButton @click="emit('open-library')">
<SvgIcon icon="mdi:view-grid-plus-outline" class="edit-overlay__btn-icon" />
模块库
</ElButton>
<ElButton @click="emit('reset')">重置默认布局</ElButton>
<ElButton @click="emit('cancel')">取消</ElButton>
<ElButton type="primary" :loading="saving" :disabled="!dirty && !saving" @click="emit('save')">
@@ -50,4 +55,7 @@ const emit = defineEmits<{
display: inline-flex;
gap: 8px;
}
.edit-overlay__btn-icon {
margin-right: 4px;
}
</style>

View File

@@ -1,72 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type WorkbenchFavoriteItem, buildWorkbenchFavoriteItems } from '../homepage';
import { workbenchFavoriteMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const items = computed(() => buildWorkbenchFavoriteItems(workbenchFavoriteMock));
function toneType(it: WorkbenchFavoriteItem): 'primary' | 'success' | 'warning' | 'danger' {
return ({ sky: 'primary', emerald: 'success', amber: 'warning', rose: 'danger' } as const)[it.kindTone];
}
</script>
<template>
<WorkbenchModuleCard
title="我的收藏"
icon="mdi:star-outline"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElAlert type="info" :closable="false" class="favorite-hint">
v1 仅展示 mock业务页"收藏"入口将在 v2 加入
</ElAlert>
<ul class="favorite-list">
<li v-for="item in items" :key="item.id" class="favorite-item">
<ElTag size="small" :type="toneType(item)">{{ item.kindLabel }}</ElTag>
<span class="favorite-title">{{ item.title }}</span>
<span class="favorite-source">{{ item.source }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.favorite-hint {
margin-bottom: 10px;
}
.favorite-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.favorite-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
padding: 8px 10px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.favorite-title {
font-weight: 500;
}
.favorite-source {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,121 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMentions' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface MentionItem {
id: string;
fromName: string;
fromAvatar: string;
context: string;
timeLabel: string;
unread: boolean;
}
const items: MentionItem[] = [
{
id: 'm1',
fromName: '李四',
fromAvatar: '李',
context: '在 任务「分片设计评审」中 @ 了你',
timeLabel: '2h 前 · 评审建议',
unread: true
},
{
id: 'm2',
fromName: '张三',
fromAvatar: '张',
context: '在 执行「迭代 24.05」中 @ 了你',
timeLabel: '昨日 · 关闭确认',
unread: true
},
{
id: 'm3',
fromName: '王五',
fromAvatar: '王',
context: '在 需求「多币种支持」中 @ 了你',
timeLabel: '3 天前',
unread: true
}
];
const unreadCount = items.filter(i => i.unread).length;
</script>
<template>
<WorkbenchModuleCard
title="@我的提及"
icon="mdi:at"
:badge-count="unreadCount"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="mention-list">
<li v-for="item in items" :key="item.id" class="mention-item">
<span class="mention-avatar">{{ item.fromAvatar }}</span>
<div class="mention-body">
<div class="mention-text">
<strong>{{ item.fromName }}</strong>
{{ item.context }}
</div>
<div class="mention-meta">{{ item.timeLabel }}</div>
</div>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.mention-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.mention-item {
display: flex;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.mention-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 13px;
}
.mention-body {
min-width: 0;
flex: 1;
}
.mention-text {
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.mention-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
</style>

View File

@@ -1,8 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, inject } from 'vue';
defineOptions({ name: 'WorkbenchModuleCard' });
// 由 src/views/workbench/index.vue provide 的"进入编辑模式"动作;
// 卡片正常态的"编辑布局"按钮直接调它,避免每个 widget 都透传 emit
const enterEditing = inject<(() => void) | null>('workbenchEnterEditing', null);
interface Props {
title: string;
icon?: string;
@@ -13,6 +17,8 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
icon: undefined,
badgeCount: undefined,
editing: false,
collapsed: false,
hasSettings: false
@@ -58,6 +64,9 @@ const showBody = computed(() => !props.collapsed);
<ElButton v-if="!editing" link size="small" title="跳详情" @click="emit('navigate')">
<SvgIcon icon="mdi:open-in-new" />
</ElButton>
<ElButton v-if="!editing && enterEditing" link size="small" title="编辑工作台布局" @click="enterEditing()">
<SvgIcon icon="mdi:view-dashboard-edit-outline" />
</ElButton>
<ElButton v-if="editing" link size="small" title="隐藏此模块" type="danger" @click="emit('hide')">
<SvgIcon icon="mdi:close" />
</ElButton>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import type {
WorkbenchColumnId,
WorkbenchModuleCategory,
@@ -9,27 +10,42 @@ interface Props {
modelValue: boolean;
hiddenMetas: WorkbenchModuleMeta[];
}
defineProps<Props>();
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void;
}>();
const categoryLabel: Record<WorkbenchModuleCategory, string> = {
// 模块库展示分三段:个人(含动作 / 快照)/ 管理 / 工具
type LibraryGroupKey = 'personal' | 'manager' | 'tool';
const groupLabel: Record<LibraryGroupKey, string> = {
personal: '个人',
manager: '管理',
tool: '工具',
action: '动作',
snapshot: '快照'
manager: '管理',
tool: '工具'
};
const categoryTagType: Record<WorkbenchModuleCategory, 'info' | 'warning' | 'success' | 'primary' | 'danger'> = {
personal: 'info',
manager: 'warning',
tool: 'info',
action: 'success',
snapshot: 'primary'
const groupOf: Record<WorkbenchModuleCategory, LibraryGroupKey> = {
personal: 'personal',
action: 'personal',
snapshot: 'personal',
manager: 'manager',
tool: 'tool'
};
const groups = computed<Array<{ key: LibraryGroupKey; label: string; items: WorkbenchModuleMeta[] }>>(() => {
const buckets: Record<LibraryGroupKey, WorkbenchModuleMeta[]> = {
personal: [],
manager: [],
tool: []
};
props.hiddenMetas.forEach(meta => {
buckets[groupOf[meta.category]].push(meta);
});
return (['personal', 'manager', 'tool'] as LibraryGroupKey[])
.filter(k => buckets[k].length > 0)
.map(k => ({ key: k, label: groupLabel[k], items: buckets[k] }));
});
</script>
<template>
@@ -42,21 +58,23 @@ const categoryTagType: Record<WorkbenchModuleCategory, 'info' | 'warning' | 'suc
>
<template #default>
<p class="hint">点击下方模块加入工作台默认进左栏</p>
<ul class="library">
<li v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</li>
<li
v-for="meta in hiddenMetas"
:key="meta.key"
class="library-item"
@click="emit('add-module', meta.key, 'left')"
>
<SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span>
<ElTag size="small" :type="categoryTagType[meta.category]">
{{ categoryLabel[meta.category] }}
</ElTag>
</li>
</ul>
<div v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</div>
<div v-else class="library">
<section v-for="group in groups" :key="group.key" class="library-group">
<h4 class="library-group__title">{{ group.label }}</h4>
<ul class="library-group__list">
<li
v-for="meta in group.items"
:key="meta.key"
class="library-item"
@click="emit('add-module', meta.key, 'left')"
>
<SvgIcon :icon="meta.icon" />
<span class="library-item__name">{{ meta.displayName }}</span>
</li>
</ul>
</section>
</div>
</template>
</ElDrawer>
</template>
@@ -68,6 +86,18 @@ const categoryTagType: Record<WorkbenchModuleCategory, 'info' | 'warning' | 'suc
margin: 0 0 12px;
}
.library {
display: flex;
flex-direction: column;
gap: 18px;
}
.library-group__title {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
letter-spacing: 0.04em;
}
.library-group__list {
list-style: none;
margin: 0;
padding: 0;

View File

@@ -1,122 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyCompletionRate' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const rate = 72; // %
const teamAvg = 65;
const total = 25;
const completed = 18;
const onTime = 13;
const overdue = 5;
const diff = rate - teamAvg;
</script>
<template>
<WorkbenchModuleCard
title="我的完成率"
icon="mdi:chart-donut"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="cr-wrap">
<div class="donut" :style="{ '--p': rate } as any">
<div class="donut-inner">
<b>{{ rate }}%</b>
<span>按时完成</span>
</div>
</div>
<div class="cr-info">
<div class="cr-line">团队均值 {{ teamAvg }}%</div>
<div class="cr-line">
任务完成
<b>{{ completed }}</b>
/ {{ total }}
</div>
<div class="cr-line">
按时
<b>{{ onTime }}</b>
· 逾期
<b>{{ overdue }}</b>
</div>
<div class="cr-diff" :class="diff >= 0 ? 'text-success' : 'text-danger'">
{{ diff >= 0 ? `高于团队均值 +${diff}%` : `低于团队均值 ${diff}%` }}
</div>
</div>
</div>
<div class="cr-hint">统计近 30 </div>
</WorkbenchModuleCard>
</template>
<style scoped>
.cr-wrap {
display: flex;
align-items: center;
gap: 16px;
}
.donut {
width: 90px;
height: 90px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-success) 0 calc(var(--p) * 1%),
var(--el-fill-color) calc(var(--p) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.donut-inner {
width: 68px;
height: 68px;
border-radius: 50%;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.donut-inner b {
font-size: 18px;
color: var(--el-text-color-primary);
}
.donut-inner span {
font-size: 10px;
color: var(--el-text-color-secondary);
}
.cr-info {
flex: 1;
font-size: 13px;
}
.cr-line {
margin: 3px 0;
}
.cr-line b {
font-weight: 700;
}
.cr-diff {
margin-top: 4px;
font-size: 12px;
}
.text-success {
color: var(--el-color-success);
}
.text-danger {
color: var(--el-color-danger);
}
.cr-hint {
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -1,4 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusTagType } from '@/views/project/project/execution/shared';
import { type WorkbenchMyExecutionItem, buildWorkbenchMyExecutionItems } from '../homepage';
import { workbenchMyExecutionMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyExecution' });
@@ -10,137 +18,252 @@ interface Props {
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ExecutionRow {
id: string;
name: string;
project: string;
done: number;
total: number;
progress: number;
statusLabel: string;
overdue: boolean;
const router = useRouter();
const items = computed(() => buildWorkbenchMyExecutionItems(workbenchMyExecutionMock));
// 按项目归类:未完成执行数多的项目在前;项目内按计划结束日升序(更紧的在前)
const groups = computed<Array<{ projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>>(() => {
const map = new Map<string, { projectId: string; projectName: string; items: WorkbenchMyExecutionItem[] }>();
items.value.forEach(item => {
if (!map.has(item.projectId)) {
map.set(item.projectId, { projectId: item.projectId, projectName: item.projectName, items: [] });
}
map.get(item.projectId)!.items.push(item);
});
const groupsArr = Array.from(map.values());
groupsArr.forEach(g => {
g.items.sort((a, b) => {
const av = a.plannedEndDate ? new Date(a.plannedEndDate).getTime() : Number.POSITIVE_INFINITY;
const bv = b.plannedEndDate ? new Date(b.plannedEndDate).getTime() : Number.POSITIVE_INFINITY;
return av - bv;
});
});
return groupsArr.sort((a, b) => b.items.length - a.items.length);
});
function goProjectExecutionPool(projectId: string) {
router.push({
path: '/project/project/execution',
query: { [OBJECT_CONTEXT_QUERY_KEY]: projectId }
});
}
const rows: ExecutionRow[] = [
{
id: 'e1',
name: '迭代 24.05',
project: '商城 V2 升级',
done: 12,
total: 15,
progress: 80,
statusLabel: '03 天后结束',
overdue: false
},
{
id: 'e2',
name: '关键路径优化',
project: '风控引擎',
done: 3,
total: 8,
progress: 38,
statusLabel: '已逾期 2 天',
overdue: true
},
{
id: 'e3',
name: '多币种支持',
project: '收银台',
done: 5,
total: 7,
progress: 71,
statusLabel: '在期内',
overdue: false
}
];
function goRequirementDetail(item: WorkbenchMyExecutionItem) {
if (!item.projectRequirementId) return;
router.push({
path: '/project/project/requirement',
query: {
[OBJECT_CONTEXT_QUERY_KEY]: item.projectId,
requirementId: item.projectRequirementId
}
});
}
</script>
<template>
<WorkbenchModuleCard
title="我负责的执行"
icon="mdi:flag-checkered"
:badge-count="rows.length"
:badge-count="items.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="exec-list">
<li v-for="row in rows" :key="row.id" class="exec-item">
<div class="exec-head">
<div class="exec-name">{{ row.name }}</div>
<div class="exec-progress">{{ row.progress }}%</div>
</div>
<div class="exec-meta">
<span>{{ row.project }}</span>
<span class="exec-sep">·</span>
<span>{{ row.done }} / {{ row.total }} 任务完成</span>
<span class="exec-sep">·</span>
<span :class="{ 'exec-overdue': row.overdue }">{{ row.statusLabel }}</span>
</div>
<div class="exec-bar">
<div class="exec-bar-inner" :class="{ 'is-overdue': row.overdue }" :style="{ width: `${row.progress}%` }" />
</div>
</li>
</ul>
<div v-if="items.length" class="exec-groups">
<section v-for="group in groups" :key="group.projectId" class="exec-group">
<header class="exec-group__head">
<SvgIcon icon="mdi:briefcase-outline" class="exec-group__icon" />
<span
class="exec-group__name"
role="button"
tabindex="0"
:title="`进入「${group.projectName}」执行池`"
@click="goProjectExecutionPool(group.projectId)"
@keydown.enter.prevent="goProjectExecutionPool(group.projectId)"
>
{{ group.projectName }}
</span>
<span class="exec-group__count">{{ group.items.length }}</span>
</header>
<ul class="exec-list">
<li v-for="item in group.items" :key="item.id" class="exec-item">
<div class="exec-head">
<span class="exec-name" :title="item.executionName">{{ item.executionName }}</span>
<DictTag
class="exec-head__tag"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:value="item.priority"
effect="light"
size="small"
/>
<ElTag
class="exec-head__tag"
size="small"
:type="getExecutionStatusTagType(item.statusCode)"
effect="light"
>
{{ item.statusName }}
</ElTag>
</div>
<div class="exec-meta">
<div class="exec-meta__row">
<SvgIcon icon="mdi:flag-outline" class="exec-meta__icon" />
<span class="exec-meta__text">
计划 {{ formatDateRange(item.plannedStartDate, item.plannedEndDate) }}
</span>
</div>
<div class="exec-meta__row">
<SvgIcon icon="mdi:calendar-outline" class="exec-meta__icon" />
<span class="exec-meta__text">
实际 {{ formatDateRange(item.actualStartDate, item.actualEndDate) }}
</span>
</div>
<div v-if="item.projectRequirementId && item.projectRequirementName" class="exec-meta__row">
<SvgIcon icon="mdi:link-variant" class="exec-meta__icon" />
<span
class="exec-meta__text exec-meta__link"
role="button"
tabindex="0"
:title="item.projectRequirementName"
@click="goRequirementDetail(item)"
@keydown.enter.prevent="goRequirementDetail(item)"
>
{{ item.projectRequirementName }}
</span>
</div>
</div>
<ElProgress :percentage="item.progressRate" :stroke-width="6" />
</li>
</ul>
</section>
</div>
<ElEmpty v-else description="暂无进行中的执行" :image-size="60" />
</WorkbenchModuleCard>
</template>
<style scoped>
.exec-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.exec-group__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 0 2px 6px;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.exec-group__icon {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.exec-group__name {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
letter-spacing: 0.01em;
cursor: pointer;
transition: color 0.16s ease;
}
.exec-group__name:hover,
.exec-group__name:focus-visible {
color: var(--el-color-primary);
outline: none;
}
.exec-group__count {
flex-shrink: 0;
min-width: 22px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 11px;
font-weight: 600;
text-align: center;
line-height: 18px;
}
.exec-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
}
.exec-item {
padding: 12px;
padding: 9px 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
border-radius: 6px;
background: var(--el-fill-color-blank);
transition:
border-color 0.16s ease,
background 0.16s ease;
}
.exec-item:hover {
border-color: var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
}
.exec-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.exec-name {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 600;
font-size: 14px;
font-size: 13px;
color: var(--el-text-color-primary);
}
.exec-progress {
font-size: 16px;
font-weight: 700;
color: var(--el-color-primary);
.exec-head__tag {
flex: 0 0 auto;
}
.exec-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 8px;
}
.exec-sep {
margin: 0 6px;
.exec-meta__row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.exec-meta__icon {
flex-shrink: 0;
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.exec-overdue {
color: var(--el-color-danger);
font-weight: 600;
}
.exec-bar {
height: 6px;
border-radius: 999px;
background: var(--el-fill-color);
.exec-meta__text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.exec-bar-inner {
height: 100%;
background: linear-gradient(90deg, var(--el-color-primary), var(--el-color-primary-light-3));
transition: width 240ms ease;
.exec-meta__link {
color: var(--el-color-primary);
cursor: pointer;
}
.exec-bar-inner.is-overdue {
background: linear-gradient(90deg, var(--el-color-danger), var(--el-color-danger-light-3));
.exec-meta__link:hover,
.exec-meta__link:focus-visible {
text-decoration: underline;
outline: none;
}
</style>

View File

@@ -1,101 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import { buildWorkbenchMyRequirementGroups } from '../homepage';
import { workbenchMyRequirementMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const { routerPushByKey } = useRouterPush();
const groups = computed(() => buildWorkbenchMyRequirementGroups(workbenchMyRequirementMock));
const total = computed(() => groups.value.reduce((s, g) => s + g.count, 0));
function handleClickGroup() {
routerPushByKey('product_list');
}
</script>
<template>
<WorkbenchModuleCard
title="我的需求"
icon="mdi:file-document-multiple-outline"
:badge-count="total"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
@navigate="handleClickGroup"
>
<div class="req-grid">
<button
v-for="group in groups"
:key="group.statusCode"
class="req-card"
:class="`tone-${group.tone}`"
@click="handleClickGroup"
>
<span class="req-card__label">{{ group.statusLabel }}</span>
<span class="req-card__count">{{ group.count }}</span>
</button>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.req-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.req-card {
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-lighter);
padding: 14px 16px;
border-radius: 8px;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
transition: all 120ms;
}
.req-card:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px var(--el-color-primary-light-9);
}
.req-card__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.req-card__count {
font-size: 24px;
font-weight: 700;
}
.tone-sky .req-card__count {
color: #0284c7;
}
.tone-amber .req-card__count {
color: #d97706;
}
.tone-emerald .req-card__count {
color: #047857;
}
.tone-rose .req-card__count {
color: #be123c;
}
</style>

View File

@@ -1,107 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyTask' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface FocusItem {
id: string;
title: string;
done: boolean;
}
const items = ref<FocusItem[]>([
{ id: 'f1', title: '提交昨日工时', done: true },
{ id: 'f2', title: '登录页 SSO 改造 · 18:00 截止', done: false },
{ id: 'f3', title: '迭代 24.05 关闭 · 跟交付 / QA 确认遗留', done: false }
]);
const doneCount = computed(() => items.value.filter(i => i.done).length);
const totalCount = computed(() => items.value.length);
const todayHours = 3.5;
const targetHours = 8;
</script>
<template>
<WorkbenchModuleCard
title="我的今日"
icon="mdi:calendar-check-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="today-head">
<span class="today-progress">{{ doneCount }} / {{ totalCount }} 已完成</span>
</div>
<ul class="today-list">
<li v-for="item in items" :key="item.id" class="today-row">
<ElCheckbox v-model="item.done" />
<span class="today-text" :class="{ 'today-done': item.done }">{{ item.title }}</span>
</li>
</ul>
<div class="today-foot">
当日累计工时
<b>{{ todayHours }}h</b>
/ {{ targetHours }}h
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.today-head {
display: flex;
justify-content: flex-end;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.today-progress {
padding: 2px 8px;
background: var(--el-fill-color-lighter);
border-radius: 999px;
}
.today-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.today-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.today-row:last-child {
border-bottom: none;
}
.today-text {
font-size: 13px;
}
.today-done {
text-decoration: line-through;
color: var(--el-text-color-placeholder);
}
.today-foot {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-secondary);
}
.today-foot b {
color: var(--el-text-color-primary);
font-weight: 700;
}
</style>

View File

@@ -1,4 +1,18 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import dayjs from 'dayjs';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { type ECOption, useEcharts } from '@/hooks/common/echarts';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import {
type WorkbenchTeamWorklogView,
type WorkbenchWeekWorklogView,
type WorkbenchWorklogDistributionItem,
buildWorkbenchTeamWorklogView,
buildWorkbenchWeekWorklogView
} from '../homepage';
import { workbenchMyWeekWorklogMock, workbenchTeamWorklogMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchMyWeekWorklog' });
@@ -10,91 +24,625 @@ interface Props {
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const days = ['周一', '周二', '周三', '周四', '周五', '今日'];
const hoursPerDay = [4, 7, 6, 8, 7.5, 0]; // 今日为 0未填报
const total = hoursPerDay.reduce((s, h) => s + h, 0);
const target = 40;
const todayProgress = 32.5; // 截至当前小时数(含历史 + 今日已提交部分)
const delta = todayProgress - target * 0.75; // 目标按周 75% 进度
const deltaText = delta >= 0 ? `领先目标 +${delta.toFixed(1)}h` : `落后目标 ${delta.toFixed(1)}h`;
const deltaClass = delta >= 0 ? 'text-success' : 'text-danger';
const router = useRouter();
// 折线坐标计算(基于 200x80 viewBox
const padX = 10;
const padY = 10;
const width = 200;
const height = 80;
const innerW = width - padX * 2;
const innerH = height - padY * 2;
const maxY = 10;
const points = hoursPerDay.map((h, i) => {
const x = padX + (i / (hoursPerDay.length - 1)) * innerW;
const y = padY + innerH - (h / maxY) * innerH;
return { x: Number(x.toFixed(1)), y: Number(y.toFixed(1)) };
// EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"。
// 我们按 ISO 周(周一-周日)存储;遇到周日 +1 天再 startOf('isoWeek'),避免回退到上一周。
function resolveIsoWeekStart(weekDate: Date | null) {
if (!weekDate) return null;
const picked = dayjs(weekDate);
if (!picked.isValid()) return null;
const aligned = picked.isoWeekday() === 7 ? picked.add(1, 'day') : picked;
return aligned.startOf('isoWeek');
}
const weekDateShortcuts = [
{ text: '本周', value: () => dayjs().startOf('isoWeek').toDate() },
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() }
];
const selectedWeekDate = ref<Date | null>(dayjs().startOf('isoWeek').toDate());
const selectedWeekStart = computed(() => {
const aligned = resolveIsoWeekStart(selectedWeekDate.value);
return aligned ? aligned.format('YYYY-MM-DD') : '';
});
type TabKey = 'my' | 'team';
const activeTab = ref<TabKey>('my');
// ============ 我的工时 ============
const myView = computed<WorkbenchWeekWorklogView | null>(() => {
if (!selectedWeekStart.value) return null;
if (selectedWeekStart.value === workbenchMyWeekWorklogMock.current.weekStart) {
return buildWorkbenchWeekWorklogView(workbenchMyWeekWorklogMock.current);
}
if (selectedWeekStart.value === workbenchMyWeekWorklogMock.previous.weekStart) {
return buildWorkbenchWeekWorklogView(workbenchMyWeekWorklogMock.previous);
}
return null;
});
const isCurrentWeek = computed(() => selectedWeekStart.value === workbenchMyWeekWorklogMock.current.weekStart);
const totalLabel = computed(() => (isCurrentWeek.value ? '累计' : '上周累计'));
const deltaInfo = computed(() => {
if (!myView.value) return null;
const { delta, completionRate } = myView.value;
if (isCurrentWeek.value) {
if (delta >= 0) return { text: `领先目标 +${delta}h`, tone: 'success' as const };
return { text: `落后目标 ${delta}h`, tone: 'danger' as const };
}
return { text: `达成 ${completionRate}%`, tone: completionRate >= 100 ? ('success' as const) : ('muted' as const) };
});
// 每日柱图的"按天/按周"分色(与项目色无关,保留本地常量)
const DAY_BAR_COLOR = '#409EFF';
const WEEK_BAR_COLOR = '#A0CFFF';
interface DistributionRow extends WorkbenchWorklogDistributionItem {
color: string;
}
const distributionRows = computed<DistributionRow[]>(() => {
const list = myView.value?.distribution ?? [];
return list.map(item => ({ ...item, color: getWorkbenchItemColor(item.key, item.kind) }));
});
function handleDistributionClick(item: WorkbenchWorklogDistributionItem) {
if (item.kind === 'project' && item.projectId) {
router.push({
path: '/project/project/execution',
query: { [OBJECT_CONTEXT_QUERY_KEY]: item.projectId }
});
return;
}
if (item.kind === 'personal') {
router.push({ path: '/personal-center/my-item' });
}
}
function buildPieOption(): ECOption {
const list = distributionRows.value;
return {
tooltip: { trigger: 'item', formatter: '{b}: {c}h ({d}%)' },
series: [
{
type: 'pie',
radius: ['38%', '58%'],
center: ['50%', '52%'],
avoidLabelOverlap: true,
minAngle: 6,
label: {
show: true,
formatter: (params: any) => `${params.name}\n${params.percent}%`,
color: '#475569',
fontSize: 11,
lineHeight: 14
},
labelLine: {
show: true,
length: 6,
length2: 8,
smooth: false
},
emphasis: {
scale: true,
scaleSize: 4,
label: { fontWeight: 700 }
},
data: list.map(item => ({
name: item.label,
value: item.hours,
itemStyle: { color: item.color },
cursor: item.kind === 'other' ? 'default' : 'pointer'
}))
}
]
};
}
function buildMyBarOption(): ECOption {
const v = myView.value;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (rawParams: any) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
const dayName = params[0]?.axisValue ?? '';
const dayPart = params.find((p: any) => p.seriesName === '按天填')?.value ?? 0;
const weekPart = params.find((p: any) => p.seriesName === '按周均分')?.value ?? 0;
const total = Number(dayPart) + Number(weekPart);
return `${dayName}${total}h<br/>按天填 ${dayPart}h<br/>按周均分 ${weekPart}h`;
}
},
grid: { left: 28, right: 8, top: 16, bottom: 24, containLabel: false },
xAxis: {
type: 'category',
data: ['一', '二', '三', '四', '五'],
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: 11 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#9ca3af', fontSize: 10 }
},
series: [
{
name: '按天填',
type: 'bar',
stack: 'total',
barWidth: 18,
data: v?.dailyByDay ?? [],
itemStyle: { color: DAY_BAR_COLOR, borderRadius: [0, 0, 2, 2] }
},
{
name: '按周均分',
type: 'bar',
stack: 'total',
barWidth: 18,
data: v?.dailyByWeekAvg ?? [],
itemStyle: { color: WEEK_BAR_COLOR, borderRadius: [2, 2, 0, 0] }
}
]
};
}
const { domRef: pieRef, updateOptions: updatePie } = useEcharts(buildPieOption, {
onRender(chart) {
chart.on('click', (params: any) => {
const item = myView.value?.distribution[params.dataIndex];
if (item) handleDistributionClick(item);
});
}
});
const { domRef: myBarRef, updateOptions: updateMyBar } = useEcharts(buildMyBarOption, { onRender() {} });
// ============ 团队工时 ============
const teamView = computed<WorkbenchTeamWorklogView | null>(() => {
if (!selectedWeekStart.value) return null;
if (selectedWeekStart.value === workbenchTeamWorklogMock.current.weekStart) {
return buildWorkbenchTeamWorklogView(workbenchTeamWorklogMock.current);
}
if (selectedWeekStart.value === workbenchTeamWorklogMock.previous.weekStart) {
return buildWorkbenchTeamWorklogView(workbenchTeamWorklogMock.previous);
}
return null;
});
const teamSeriesWithColor = computed(() =>
(teamView.value?.seriesMatrix ?? []).map(s => ({ ...s, color: getWorkbenchItemColor(s.key, s.kind) }))
);
function buildTeamBarOption(): ECOption {
const v = teamView.value;
if (!v) return {};
const colored = teamSeriesWithColor.value;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (rawParams: any) => {
const params = Array.isArray(rawParams) ? rawParams : [rawParams];
if (!params.length) return '';
const name = params[0].axisValue as string;
const total = params.reduce((s: number, p: any) => s + (Number(p.value) || 0), 0);
const lines = params
.filter((p: any) => Number(p.value) > 0)
.map((p: any) => `${p.marker}${p.seriesName} <b style="margin-left:6px">${p.value}h</b>`)
.join('<br/>');
return `<div style="font-weight:600;margin-bottom:4px">${name} · ${total}h</div>${lines}`;
}
},
legend: {
type: 'scroll',
bottom: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: '#6b7280', fontSize: 11 }
},
grid: { left: 32, right: 12, top: 16, bottom: 40, containLabel: false },
xAxis: {
type: 'category',
data: v.members.map(m => m.memberName),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: 11 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: '{value}h' }
},
series: colored.map((s, i) => ({
name: s.label,
type: 'bar',
stack: 'member',
barWidth: 22,
data: s.data,
itemStyle: {
color: s.color,
borderRadius: i === colored.length - 1 ? [3, 3, 0, 0] : 0
}
}))
};
}
const { domRef: teamBarRef, updateOptions: updateTeamBar } = useEcharts(buildTeamBarOption, { onRender() {} });
// ============ 数据 / 切换变化时刷新 echarts ============
watch(myView, () => {
updatePie((_, factory) => factory());
updateMyBar((_, factory) => factory());
});
watch(teamView, () => {
updateTeamBar((_, factory) => factory());
});
// 切 tab 后等 v-show 容器渲染再触发 echarts 重画(避免 display:none → block 切换时尺寸残留为 0
watch(activeTab, async tab => {
await nextTick();
if (tab === 'my') {
updatePie((_, factory) => factory());
updateMyBar((_, factory) => factory());
} else {
updateTeamBar((_, factory) => factory());
}
});
const polyline = points.map(p => `${p.x},${p.y}`).join(' ');
</script>
<template>
<WorkbenchModuleCard
title="我的本周工时"
icon="mdi:chart-line"
title="工时"
icon="mdi:timer-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<svg viewBox="0 0 200 80" preserveAspectRatio="none" class="spark">
<line x1="0" y1="20" x2="200" y2="20" stroke="var(--el-border-color-lighter)" stroke-dasharray="3,3" />
<polyline :points="polyline" fill="none" stroke="var(--el-color-primary)" stroke-width="2" />
<circle v-for="(p, i) in points" :key="i" :cx="p.x" :cy="p.y" r="3" fill="var(--el-color-primary)" />
</svg>
<div class="ww-x">
<span v-for="d in days" :key="d">{{ d }}</span>
<div class="ww-tabbar">
<ElTabs v-model="activeTab" class="ww-tabs">
<ElTabPane label="我的工时" name="my" />
<ElTabPane label="团队工时" name="team" />
</ElTabs>
<ElDatePicker
v-model="selectedWeekDate"
type="week"
format="YYYY[年第]ww[周]"
placeholder="选择周次"
:shortcuts="weekDateShortcuts"
:clearable="false"
size="small"
class="ww-week-picker"
/>
</div>
<div class="ww-foot">
<span>
累计
<b>{{ todayProgress }}h</b>
/ {{ target }}h
</span>
<span :class="deltaClass">{{ deltaText }}</span>
<!-- ============ 我的工时 tab ============ -->
<div v-show="activeTab === 'my'">
<template v-if="myView">
<div class="ww-headline">
<div class="ww-section-title">
<SvgIcon icon="mdi:chart-donut" class="ww-section-icon" />
<span>工时分布</span>
</div>
<div class="ww-section-title">
<SvgIcon icon="mdi:calendar-week" class="ww-section-icon" />
<span>每日工时</span>
</div>
</div>
<div class="ww-grid">
<div class="ww-block">
<div class="ww-pie-wrap">
<div ref="pieRef" class="ww-pie" />
</div>
</div>
<div class="ww-block">
<div ref="myBarRef" class="ww-bar" />
<div class="ww-bar-legend">
<span class="ww-bar-legend__item">
<span class="ww-bar-legend__swatch" :style="{ background: DAY_BAR_COLOR }" />
按天填
</span>
<span class="ww-bar-legend__item">
<span class="ww-bar-legend__swatch" :style="{ background: WEEK_BAR_COLOR }" />
按周均分
</span>
</div>
</div>
</div>
<div class="ww-footer">
<span>
{{ totalLabel }}
<b>{{ myView.totalHours }}h</b>
/ {{ myView.target }}h
</span>
<span v-if="deltaInfo" :class="`ww-footer__delta is-${deltaInfo.tone}`">{{ deltaInfo.text }}</span>
</div>
</template>
<ElEmpty v-else description="该周无工时数据" :image-size="60" />
</div>
<!-- ============ 团队工时 tab ============ -->
<div v-show="activeTab === 'team'">
<template v-if="teamView">
<div class="tw-kpis">
<div class="tw-kpi">
<span class="tw-kpi__label">填报率</span>
<span class="tw-kpi__value">
{{ teamView.fillRate }}
<span class="tw-kpi__unit">%</span>
</span>
<span class="tw-kpi__sub">{{ teamView.totalHours }}h / {{ teamView.expectedTotalHours }}h</span>
</div>
<div class="tw-kpi">
<span class="tw-kpi__label">团队均值</span>
<span class="tw-kpi__value">
{{ teamView.averageHours }}
<span class="tw-kpi__unit">h</span>
</span>
<span class="tw-kpi__sub">{{ teamView.members.length }} </span>
</div>
<div class="tw-kpi">
<span class="tw-kpi__label">偏低</span>
<span class="tw-kpi__value" :class="{ 'is-danger': teamView.lowCount > 0 }">
{{ teamView.lowCount }}
<span class="tw-kpi__unit"></span>
</span>
<span class="tw-kpi__sub">低于均值 80%</span>
</div>
<div class="tw-kpi">
<span class="tw-kpi__label">加班</span>
<span class="tw-kpi__value" :class="{ 'is-warn': teamView.highCount > 0 }">
{{ teamView.highCount }}
<span class="tw-kpi__unit"></span>
</span>
<span class="tw-kpi__sub"> 45h</span>
</div>
</div>
<div ref="teamBarRef" class="tw-bar" />
<div v-if="teamView.lowest && teamView.highest" class="tw-footer">
<span>
<SvgIcon icon="mdi:arrow-down-bold-circle-outline" class="tw-footer__icon is-danger" />
最低
<b>{{ teamView.lowest.memberName }}</b>
{{ teamView.lowest.hours }}h
</span>
<span class="tw-footer__sep">·</span>
<span>
<SvgIcon icon="mdi:arrow-up-bold-circle-outline" class="tw-footer__icon is-warn" />
最高
<b>{{ teamView.highest.memberName }}</b>
{{ teamView.highest.hours }}h
</span>
</div>
</template>
<ElEmpty v-else description="该周无团队工时数据" :image-size="60" />
</div>
<div class="ww-hint">本周总和含今日{{ total.toFixed(1) }}h</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.spark {
width: 100%;
height: 80px;
display: block;
}
.ww-x {
/* ============ 顶部 tab + 周选择器 ============ */
.ww-tabbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ww-tabs {
flex: 1;
min-width: 0;
}
.ww-tabs :deep(.el-tabs__header) {
margin: 0;
}
.ww-tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.ww-week-picker {
width: 180px;
flex-shrink: 0;
margin-bottom: 8px;
}
/* ============ 我的工时 ============ */
.ww-headline {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: 16px;
margin-bottom: 10px;
}
.ww-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
@media (width <= 520px) {
.ww-grid {
grid-template-columns: 1fr;
}
.ww-headline {
grid-template-columns: 1fr;
}
}
.ww-block {
display: flex;
flex-direction: column;
min-width: 0;
}
.ww-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
min-width: 0;
}
.ww-section-icon {
font-size: 14px;
color: var(--el-text-color-placeholder);
flex-shrink: 0;
}
.ww-pie-wrap {
position: relative;
width: 100%;
height: 280px;
}
.ww-pie {
width: 100%;
height: 100%;
}
.ww-bar {
width: 100%;
height: 280px;
}
.ww-bar-legend {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.ww-foot {
.ww-bar-legend__item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ww-bar-legend__swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.ww-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 13px;
margin-top: 10px;
}
.ww-foot b {
.ww-footer b {
font-weight: 700;
}
.ww-hint {
margin-top: 4px;
.ww-footer__delta {
font-weight: 600;
}
.ww-footer__delta.is-success {
color: var(--el-color-success);
}
.ww-footer__delta.is-danger {
color: var(--el-color-danger);
}
.ww-footer__delta.is-muted {
color: var(--el-text-color-secondary);
}
/* ============ 团队工时 ============ */
.tw-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.tw-kpi {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
min-width: 0;
}
.tw-kpi__label {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.tw-kpi__value {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.2;
}
.tw-kpi__value.is-danger {
color: var(--el-color-danger);
}
.tw-kpi__value.is-warn {
color: var(--el-color-warning);
}
.tw-kpi__unit {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
margin-left: 2px;
}
.tw-kpi__sub {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.text-success {
color: var(--el-color-success);
.tw-bar {
width: 100%;
height: 240px;
}
.text-danger {
.tw-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.tw-footer b {
color: var(--el-text-color-primary);
font-weight: 600;
margin: 0 2px;
}
.tw-footer__icon {
vertical-align: -2px;
margin-right: 2px;
font-size: 13px;
}
.tw-footer__icon.is-danger {
color: var(--el-color-danger);
}
.tw-footer__icon.is-warn {
color: var(--el-color-warning);
}
.tw-footer__sep {
color: var(--el-text-color-placeholder);
}
@media (width <= 520px) {
.tw-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchNoticeNotification' });
@@ -10,54 +11,119 @@ interface Props {
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface Row {
interface NoticeRow {
id: string;
title: string;
timeLabel: string;
}
const notices: Row[] = [
interface NotificationRow {
id: string;
title: string;
timeLabel: string;
unread: boolean;
}
const notices: NoticeRow[] = [
{ id: 'n1', title: '【运维】本周六 02:00-04:00 数据库主从切换', timeLabel: '2 天前' },
{ id: 'n2', title: '【HR】Q2 OKR 复盘截止 06-05', timeLabel: '3 天前' },
{ id: 'n3', title: '【流程】工单 SLA 新规则即将上线', timeLabel: '1 周前' }
];
const notifications: Row[] = [
{ id: 'm1', title: '你被指派为执行「迭代 24.06」负责人', timeLabel: '10min 前' },
{ id: 'm2', title: '任务「SSO 改造」状态变更:开发中 → 待验收', timeLabel: '2h 前' },
{ id: 'm3', title: '需求「多币种支持」评审通过', timeLabel: '昨日' }
const notifications: NotificationRow[] = [
{ id: 'm1', title: '你被指派为执行「迭代 24.06」负责人', timeLabel: '10min 前', unread: true },
{ id: 'm2', title: '任务「SSO 改造」状态变更:开发中 → 待验收', timeLabel: '2h 前', unread: true },
{ id: 'm3', title: '需求「多币种支持」评审通过', timeLabel: '昨日', unread: false }
];
const unreadCount = computed(() => notifications.filter(n => n.unread).length);
// mock 阶段:交互函数留占位,等后端接口落地后接通
function handleOpenNotification(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 跳对应业务对象详情
// eslint-disable-next-line no-console
console.warn('[notification] open', row.id);
}
function handleMarkRead(row: NotificationRow) {
// eslint-disable-next-line no-warning-comments
// TODO: 调标已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-read', row.id);
}
function handleMarkAllRead() {
// eslint-disable-next-line no-warning-comments
// TODO: 调一键全部已读接口
// eslint-disable-next-line no-console
console.warn('[notification] mark-all-read');
}
</script>
<template>
<WorkbenchModuleCard
title="公告 + 通知"
title="公告通知"
icon="mdi:bullhorn-outline"
:badge-count="notifications.length"
:badge-count="unreadCount || undefined"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="nn-grid">
<div class="nn-col">
<div class="nn-h">📢 公告</div>
<!-- 1/3公告只读露头扫一眼 -->
<section class="nn-col nn-col--notice">
<header class="nn-h">
<SvgIcon icon="mdi:bullhorn-outline" class="nn-h__icon" />
<span class="nn-h__title">公告</span>
<span class="nn-h__count">{{ notices.length }}</span>
</header>
<ul class="nn-list">
<li v-for="row in notices" :key="row.id">
<span class="nn-title">{{ row.title }}</span>
<span class="nn-time">{{ row.timeLabel }}</span>
<li v-for="row in notices" :key="row.id" class="nn-notice">
<div class="nn-notice__title">{{ row.title }}</div>
<div class="nn-notice__time">{{ row.timeLabel }}</div>
</li>
</ul>
</div>
<div class="nn-col">
<div class="nn-h">🔔 系统通知未读 {{ notifications.length }}</div>
</section>
<!-- 2/3通知可操作按行跳详情/标已读 -->
<section class="nn-col nn-col--notify">
<header class="nn-h">
<SvgIcon icon="mdi:bell-outline" class="nn-h__icon" />
<span class="nn-h__title">通知</span>
<span v-if="unreadCount > 0" class="nn-h__count is-unread">未读 {{ unreadCount }}</span>
<span v-else class="nn-h__count">{{ notifications.length }}</span>
<ElButton v-if="unreadCount > 0" link size="small" class="nn-h__action" @click="handleMarkAllRead">
全部已读
</ElButton>
</header>
<ul class="nn-list">
<li v-for="row in notifications" :key="row.id">
<span class="nn-title">{{ row.title }}</span>
<span class="nn-time">{{ row.timeLabel }}</span>
<li
v-for="row in notifications"
:key="row.id"
class="nn-notify"
:class="{ 'is-unread': row.unread }"
@click="handleOpenNotification(row)"
>
<span v-if="row.unread" class="nn-notify__dot" />
<span class="nn-notify__title">{{ row.title }}</span>
<span class="nn-notify__time">{{ row.timeLabel }}</span>
<span class="nn-notify__actions">
<ElTooltip v-if="row.unread" content="标为已读" placement="top">
<button class="nn-notify__act" @click.stop="handleMarkRead(row)">
<SvgIcon icon="mdi:check" />
</button>
</ElTooltip>
<ElTooltip content="跳详情" placement="top">
<button class="nn-notify__act" @click.stop="handleOpenNotification(row)">
<SvgIcon icon="mdi:open-in-new" />
</button>
</ElTooltip>
</span>
</li>
</ul>
</div>
</section>
</div>
</WorkbenchModuleCard>
</template>
@@ -65,50 +131,163 @@ const notifications: Row[] = [
<style scoped>
.nn-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
grid-template-columns: 1fr 2fr;
gap: 16px;
}
.nn-col {
min-width: 0;
}
.nn-h {
font-size: 11px;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 600;
margin-bottom: 6px;
}
.nn-h__icon {
color: var(--el-color-primary);
font-size: 14px;
}
.nn-h__title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.nn-h__count {
padding: 1px 7px;
border-radius: 999px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 11px;
line-height: 1.5;
}
.nn-h__count.is-unread {
background: var(--el-color-danger);
color: #fff;
font-weight: 600;
}
.nn-h__action {
margin-left: auto;
font-size: 11px;
}
.nn-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
}
.nn-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
gap: 8px;
.nn-list::-webkit-scrollbar {
width: 6px;
}
.nn-list li:last-child {
.nn-list::-webkit-scrollbar-thumb {
background: var(--el-fill-color-darker);
border-radius: 3px;
}
.nn-list::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
/* 公告行:纯阅读 + 标题 2 行 clamp */
.nn-notice {
padding: 7px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.nn-notice:last-child {
border-bottom: none;
}
.nn-title {
flex: 1;
.nn-notice__title {
font-size: 12.5px;
line-height: 1.5;
color: var(--el-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.nn-notice__time {
margin-top: 3px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
/* 通知行:可操作 + hover 浮出动作按钮 */
.nn-notify {
display: grid;
grid-template-columns: 8px 1fr auto auto;
align-items: center;
gap: 8px;
padding: 8px 8px;
margin: 0 -8px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 120ms;
}
.nn-notify + .nn-notify {
border-top: 1px dashed var(--el-border-color-lighter);
}
.nn-notify:hover {
background: var(--el-fill-color-light);
}
.nn-notify__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-primary);
}
.nn-notify:not(.is-unread) .nn-notify__dot {
background: transparent;
}
.nn-notify__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--el-text-color-regular);
}
.nn-time {
.nn-notify.is-unread .nn-notify__title {
color: var(--el-text-color-primary);
font-weight: 500;
}
.nn-notify__time {
font-size: 11px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
white-space: nowrap;
}
@media (width <= 1280px) {
.nn-grid {
grid-template-columns: 1fr;
}
.nn-notify__actions {
display: inline-flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 120ms;
}
.nn-notify:hover .nn-notify__actions {
opacity: 1;
}
.nn-notify__act {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 13px;
transition: background-color 120ms;
}
.nn-notify__act:hover {
background: var(--el-fill-color);
color: var(--el-color-primary);
}
</style>

View File

@@ -1,73 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchPersonalItem' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ItemRow {
id: string;
title: string;
done: boolean;
}
const items = ref<ItemRow[]>([
{ id: 'p1', title: '周会准备 · 跨境支付方案 PPT', done: false },
{ id: 'p2', title: '整理 Q2 OKR 复盘材料', done: false },
{ id: 'p3', title: '回复采购系统迁移邮件', done: true },
{ id: 'p4', title: '技术分享话题征集', done: false },
{ id: 'p5', title: '新人 Onboarding · 文档整理', done: false }
]);
</script>
<template>
<WorkbenchModuleCard
title="我的个人事项"
icon="mdi:format-list-checks"
:badge-count="items.filter(i => !i.done).length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="item-list">
<li v-for="item in items" :key="item.id" class="item-row">
<ElCheckbox v-model="item.done" />
<span class="item-text" :class="{ 'item-done': item.done }">{{ item.title }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.item-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.item-row:last-child {
border-bottom: none;
}
.item-text {
font-size: 13px;
}
.item-done {
text-decoration: line-through;
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -77,7 +77,7 @@ function onChange(id: string) {
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="ps-head">
<span class="ps-pin-label">pin</span>
<span class="ps-pin-label">当前产品</span>
<ElSelect v-model="pinnedId" size="small" class="ps-pin" @change="onChange">
<ElOption v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import { buildWorkbenchProjectItems } from '../homepage';
import { workbenchProjectMock } from '../mock';
import { buildWorkbenchOwnedProjectItems, buildWorkbenchProjectItems } from '../homepage';
import { workbenchOwnedProjectMock, workbenchProjectMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectGrid' });
@@ -21,7 +21,20 @@ defineEmits<{
const { routerPushByKey } = useRouterPush();
const items = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
type ProjectViewKey = 'participated' | 'owned';
const activeView = ref<ProjectViewKey>('participated');
const participatedItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
const ownedItems = computed(() => buildWorkbenchOwnedProjectItems(workbenchOwnedProjectMock));
const currentOwnedId = ref<string>(ownedItems.value[0]?.id ?? '');
watch(ownedItems, list => {
if (!list.find(item => item.id === currentOwnedId.value)) {
currentOwnedId.value = list[0]?.id ?? '';
}
});
const currentOwned = computed(() => ownedItems.value.find(item => item.id === currentOwnedId.value) ?? null);
function handleEnterProjectList() {
routerPushByKey('project_list');
@@ -30,84 +43,153 @@ function handleEnterProjectList() {
<template>
<WorkbenchModuleCard
title="我参与的项目"
title="我的项目"
icon="mdi:briefcase-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="workbench-project__subheader">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<div class="workbench-project__tabs">
<ElRadioGroup v-model="activeView" size="small">
<ElRadioButton value="participated">我参与的</ElRadioButton>
<ElRadioButton value="owned">我负责的</ElRadioButton>
</ElRadioGroup>
<ElButton type="primary" link @click="handleEnterProjectList">
<span>进入项目列表</span>
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton>
</div>
<div v-if="items.length" class="workbench-project__grid">
<article v-for="item in items" :key="item.id" class="workbench-project__card">
<div class="workbench-project__card-header">
<div class="workbench-project__card-title-group">
<h4 class="workbench-project__card-title">{{ item.name }}</h4>
<span class="workbench-project__card-code">{{ item.code }}</span>
<!-- 我参与的网格视图 -->
<template v-if="activeView === 'participated'">
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
<div v-if="participatedItems.length" class="workbench-project__grid">
<article v-for="item in participatedItems" :key="item.id" class="workbench-project__card">
<div class="workbench-project__card-header">
<div class="workbench-project__card-title-group">
<h4 class="workbench-project__card-title">{{ item.name }}</h4>
<span class="workbench-project__card-code">{{ item.code }}</span>
</div>
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`">
{{ item.statusLabel }}
</span>
</div>
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`">
{{ item.statusLabel }}
</span>
<div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole }}</strong>
</div>
<div class="workbench-project__progress">
<div class="workbench-project__progress-header">
<span class="workbench-project__progress-label">进度</span>
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
</div>
<div class="workbench-project__progress-bar">
<div
class="workbench-project__progress-bar-inner"
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
:style="{ width: `${item.progress}%` }"
/>
</div>
</div>
<div class="workbench-project__footer">
<div class="workbench-project__footer-block">
<span class="workbench-project__footer-label">我负责的任务</span>
<strong class="workbench-project__footer-value">
{{ item.myTaskCount }}
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
待处理 {{ item.myPendingTaskCount }}
</span>
</strong>
</div>
<div class="workbench-project__footer-block workbench-project__footer-block--right">
<span class="workbench-project__footer-label">最近活动</span>
<strong class="workbench-project__footer-value">{{ item.lastActiveLabel }}</strong>
</div>
</div>
</article>
</div>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
</template>
<!-- 我负责的单对象深度详情 -->
<template v-else>
<ElEmpty v-if="!currentOwned" description="您当前没有负责的项目" :image-size="72" />
<template v-else>
<div v-if="ownedItems.length > 1" class="ps-head">
<span class="ps-pin-label">当前项目</span>
<ElSelect v-model="currentOwnedId" size="small" class="ps-pin">
<ElOption v-for="p in ownedItems" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div v-else class="ps-head ps-head--single">
<span class="ps-pin-label">当前项目</span>
<strong class="ps-single-name">{{ currentOwned.name }}</strong>
</div>
<div class="workbench-project__card-role">
<span class="workbench-project__card-role-label">我的角色</span>
<strong class="workbench-project__card-role-value">{{ item.myRole }}</strong>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': currentOwned.progress } as any">
<span>{{ currentOwned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ currentOwned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ currentOwned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': currentOwned.overdueCount > 0 }">{{ currentOwned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div class="ps-sub"> {{ currentOwned.remainingDays }} · 我的角色{{ currentOwned.myRole }}</div>
<div class="workbench-project__progress">
<div class="workbench-project__progress-header">
<span class="workbench-project__progress-label">进度</span>
<strong class="workbench-project__progress-value">{{ item.progress }}%</strong>
</div>
<div class="workbench-project__progress-bar">
<div
class="workbench-project__progress-bar-inner"
:class="`workbench-project__progress-bar-inner--${item.statusTone}`"
:style="{ width: `${item.progress}%` }"
/>
</div>
</div>
<div class="ps-section-title">📌 本周关键节点</div>
<ul class="ps-milestones">
<li v-for="m in currentOwned.milestones" :key="m.id">
<span>{{ m.title }}</span>
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span>
</li>
</ul>
<div class="workbench-project__footer">
<div class="workbench-project__footer-block">
<span class="workbench-project__footer-label">我负责的任务</span>
<strong class="workbench-project__footer-value">
{{ item.myTaskCount }}
<span v-if="item.myPendingTaskCount > 0" class="workbench-project__footer-sub">
待处理 {{ item.myPendingTaskCount }}
</span>
</strong>
</div>
<div class="workbench-project__footer-block workbench-project__footer-block--right">
<span class="workbench-project__footer-label">最近活动</span>
<strong class="workbench-project__footer-value">{{ item.lastActiveLabel }}</strong>
</div>
</div>
</article>
</div>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
<div class="ps-section-title">👥 成员负载</div>
<ul class="ps-members">
<li v-for="m in currentOwned.members" :key="m.name">
<span class="ps-member-name">{{ m.name }}</span>
<div class="ps-bar">
<div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.load}%` }" />
</div>
<span class="ps-member-load">{{ Math.round(m.load / 10) }}</span>
</li>
</ul>
</template>
</template>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-project__subheader {
.workbench-project__tabs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
margin-bottom: 12px;
}
.workbench-project__desc {
margin: 0;
margin: 0 0 14px;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
@@ -306,4 +388,145 @@ function handleEnterProjectList() {
grid-template-columns: 1fr;
}
}
/* ===== 我负责的:单对象深度详情样式 ===== */
.ps-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ps-pin-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ps-pin {
width: 220px;
}
.ps-head--single .ps-single-name {
font-size: 14px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.ps-overview {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ps-ring {
width: 50px;
height: 50px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-success) 0 calc(var(--p) * 1%),
var(--el-fill-color) calc(var(--p) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ps-ring span {
background: #fff;
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.ps-kpis {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.ps-kpi b {
display: block;
font-size: 16px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.ps-kpi b.is-danger {
color: var(--el-color-danger);
}
.ps-kpi span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-sub {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.ps-section-title {
margin-top: 12px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
.ps-milestones,
.ps-members {
list-style: none;
margin: 0;
padding: 0;
}
.ps-milestones li {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.ps-milestones li:last-child {
border-bottom: none;
}
.ps-time {
font-size: 12px;
font-weight: 600;
}
.ps-time.tone-amber {
color: var(--el-color-warning);
}
.ps-time.tone-slate {
color: var(--el-text-color-secondary);
}
.ps-members li {
display: grid;
grid-template-columns: 60px 1fr 30px;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
}
.ps-bar {
height: 6px;
border-radius: 3px;
background: var(--el-fill-color);
overflow: hidden;
}
.ps-bar-inner {
height: 100%;
}
.ps-bar-inner.is-ok {
background: var(--el-color-success);
}
.ps-bar-inner.is-warn {
background: var(--el-color-warning);
}
.ps-bar-inner.is-over {
background: var(--el-color-danger);
}
.ps-member-load {
text-align: right;
color: var(--el-text-color-secondary);
font-size: 11px;
}
</style>

View File

@@ -40,6 +40,17 @@ const productCards: ProductHealth[] = [
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="demo-banner">
<SvgIcon icon="mdi:alert-circle-outline" class="demo-banner__icon" />
<div class="demo-banner__text">
<b>演示态 · 当前为效果预览</b>
<span>
//绿健康结论依赖"风险登记 / 计划基线 / 需求阻塞与 SLA"RDMS
业务侧暂无对应流程等业务流落地后再接通真实数据
</span>
</div>
</div>
<div class="section-title">项目</div>
<div class="health-list">
<div v-for="card in projectCards" :key="card.projectId" class="health-card">
@@ -77,6 +88,37 @@ const productCards: ProductHealth[] = [
</template>
<style scoped>
.demo-banner {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
margin-bottom: 12px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-7);
border-left: 3px solid var(--el-color-warning);
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
}
.demo-banner__icon {
color: var(--el-color-warning);
font-size: 16px;
flex-shrink: 0;
margin-top: 1px;
}
.demo-banner__text {
flex: 1;
min-width: 0;
color: var(--el-text-color-regular);
}
.demo-banner__text b {
display: block;
color: var(--el-color-warning-dark-2);
font-weight: 600;
margin-bottom: 2px;
}
.section-title {
font-size: 12px;
font-weight: 600;

View File

@@ -1,275 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchProjectSnapshot' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface ProjectOption {
id: string;
name: string;
progress: number;
executionCount: number;
taskCount: number;
memberCount: number;
overdueCount: number;
remainingDays: number;
myRole: string;
milestones: Array<{ id: string; title: string; timeLabel: string; tone: 'amber' | 'slate' }>;
members: Array<{ name: string; load: number; level: 'ok' | 'warn' | 'over' }>;
}
const projects: ProjectOption[] = [
{
id: 'p1',
name: '商城 V2 升级',
progress: 70,
executionCount: 5,
taskCount: 32,
memberCount: 6,
overdueCount: 1,
remainingDays: 12,
myRole: '负责人',
milestones: [
{ id: 'm1', title: 'SSO 改造提测', timeLabel: '今日 18:00', tone: 'amber' },
{ id: 'm2', title: '迭代 24.05 关闭', timeLabel: '今日', tone: 'amber' },
{ id: 'm3', title: '多币种支持评审', timeLabel: '05-26', tone: 'slate' }
],
members: [
{ name: '张三', load: 50, level: 'ok' },
{ name: '李四', load: 30, level: 'ok' },
{ name: '王五', load: 90, level: 'over' }
]
},
{
id: 'p2',
name: '风控引擎接入',
progress: 45,
executionCount: 3,
taskCount: 18,
memberCount: 4,
overdueCount: 2,
remainingDays: 30,
myRole: '协办人',
milestones: [
{ id: 'm4', title: '分片设计评审', timeLabel: '明日', tone: 'amber' },
{ id: 'm5', title: '缓存穿透优化交付', timeLabel: '05-28', tone: 'slate' }
],
members: [
{ name: '李四', load: 30, level: 'ok' },
{ name: '钱七', load: 65, level: 'warn' }
]
}
];
const pinnedId = ref(projects[0].id);
const pinned = ref(projects[0]);
function onChange(id: string) {
const found = projects.find(p => p.id === id);
if (found) pinned.value = found;
}
</script>
<template>
<WorkbenchModuleCard
title="项目深度快照"
icon="mdi:image-area"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="ps-head">
<span class="ps-pin-label">pin</span>
<ElSelect v-model="pinnedId" size="small" class="ps-pin" @change="onChange">
<ElOption v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</ElSelect>
</div>
<div class="ps-overview">
<div class="ps-ring" :style="{ '--p': pinned.progress } as any">
<span>{{ pinned.progress }}%</span>
</div>
<div class="ps-kpis">
<div class="ps-kpi">
<b>{{ pinned.executionCount }}</b>
<span>执行</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.taskCount }}</b>
<span>任务</span>
</div>
<div class="ps-kpi">
<b>{{ pinned.memberCount }}</b>
<span>成员</span>
</div>
<div class="ps-kpi">
<b :class="{ 'is-danger': pinned.overdueCount > 0 }">{{ pinned.overdueCount }}</b>
<span>逾期</span>
</div>
</div>
</div>
<div class="ps-sub"> {{ pinned.remainingDays }} · 我的角色{{ pinned.myRole }}</div>
<div class="ps-section-title">📌 本周关键节点</div>
<ul class="ps-milestones">
<li v-for="m in pinned.milestones" :key="m.id">
<span>{{ m.title }}</span>
<span :class="`ps-time tone-${m.tone}`">{{ m.timeLabel }}</span>
</li>
</ul>
<div v-if="pinned.myRole === '负责人'" class="ps-section-title">👥 成员负载</div>
<ul v-if="pinned.myRole === '负责人'" class="ps-members">
<li v-for="m in pinned.members" :key="m.name">
<span class="ps-member-name">{{ m.name }}</span>
<div class="ps-bar"><div class="ps-bar-inner" :class="`is-${m.level}`" :style="{ width: `${m.load}%` }" /></div>
<span class="ps-member-load">{{ Math.round(m.load / 10) }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.ps-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ps-pin-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ps-pin {
width: 180px;
}
.ps-overview {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ps-ring {
width: 50px;
height: 50px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-success) 0 calc(var(--p) * 1%),
var(--el-fill-color) calc(var(--p) * 1%) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ps-ring span {
background: #fff;
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.ps-kpis {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.ps-kpi b {
display: block;
font-size: 16px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.ps-kpi b.is-danger {
color: var(--el-color-danger);
}
.ps-kpi span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.ps-sub {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.ps-section-title {
margin-top: 12px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
.ps-milestones,
.ps-members {
list-style: none;
margin: 0;
padding: 0;
}
.ps-milestones li {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.ps-milestones li:last-child {
border-bottom: none;
}
.ps-time {
font-size: 12px;
font-weight: 600;
}
.ps-time.tone-amber {
color: var(--el-color-warning);
}
.ps-time.tone-slate {
color: var(--el-text-color-secondary);
}
.ps-members li {
display: grid;
grid-template-columns: 60px 1fr 30px;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
}
.ps-bar {
height: 6px;
border-radius: 3px;
background: var(--el-fill-color);
overflow: hidden;
}
.ps-bar-inner {
height: 100%;
}
.ps-bar-inner.is-ok {
background: var(--el-color-success);
}
.ps-bar-inner.is-warn {
background: var(--el-color-warning);
}
.ps-bar-inner.is-over {
background: var(--el-color-danger);
}
.ps-member-load {
text-align: right;
color: var(--el-text-color-secondary);
font-size: 11px;
}
</style>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchRecentVisit' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface VisitRow {
id: string;
title: string;
type: '项目' | '执行' | '产品' | '需求';
timeLabel: string;
}
const rows: VisitRow[] = [
{ id: 'v1', title: '商城 V2 升级', type: '项目', timeLabel: '2h 前' },
{ id: 'v2', title: '迭代 24.05', type: '执行', timeLabel: '今日' },
{ id: 'v3', title: '分片设计评审', type: '需求', timeLabel: '昨日' },
{ id: 'v4', title: '收银台', type: '产品', timeLabel: '2 天前' },
{ id: 'v5', title: '风控引擎接入', type: '项目', timeLabel: '3 天前' }
];
function typeTag(t: VisitRow['type']): 'primary' | 'success' | 'warning' | 'info' {
return ({ 项目: 'primary', 执行: 'success', 产品: 'warning', 需求: 'info' } as const)[t];
}
</script>
<template>
<WorkbenchModuleCard
title="最近访问"
icon="mdi:history"
:badge-count="rows.length"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="visit-list">
<li v-for="row in rows" :key="row.id" class="visit-item">
<ElTag size="small" :type="typeTag(row.type)">{{ row.type }}</ElTag>
<span class="visit-title">{{ row.title }}</span>
<span class="visit-time">{{ row.timeLabel }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.visit-list {
list-style: none;
margin: 0;
padding: 0;
}
.visit-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
padding: 8px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 13px;
}
.visit-item:last-child {
border-bottom: none;
}
.visit-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.visit-time {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,124 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchRiskAlert' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface Stat {
n: number;
label: string;
tone: 'rose' | 'amber' | 'sky';
}
interface RiskRow {
id: string;
title: string;
owner: string;
sub: string;
tone: 'rose' | 'amber';
}
const stats: Stat[] = [
{ n: 3, label: '逾期任务', tone: 'rose' },
{ n: 2, label: '停滞执行 (>7d)', tone: 'amber' },
{ n: 2, label: '超时未关闭工单', tone: 'rose' }
];
const rows: RiskRow[] = [
{ id: 'r1', title: '缓存穿透优化', owner: '李四', sub: '逾期 2 天', tone: 'rose' },
{ id: 'r2', title: '商户后台登录异常', owner: '张三', sub: 'SLA 超 2h', tone: 'rose' },
{ id: 'r3', title: '关键路径优化', owner: '风控引擎', sub: '停滞 9 天', tone: 'amber' }
];
</script>
<template>
<WorkbenchModuleCard
title="风险预警"
icon="mdi:alert-octagon-outline"
:badge-count="stats.reduce((s, x) => s + x.n, 0)"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="risk-grid">
<div v-for="s in stats" :key="s.label" class="risk-cell" :class="`tone-${s.tone}`">
<div class="risk-n">{{ s.n }}</div>
<div class="risk-lbl">{{ s.label }}</div>
</div>
</div>
<ul class="risk-list">
<li v-for="row in rows" :key="row.id" class="risk-row">
<span class="risk-title" :class="`tone-${row.tone}`">{{ row.title }} · {{ row.owner }}</span>
<span class="risk-sub">{{ row.sub }}</span>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.risk-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.risk-cell {
padding: 10px;
border-radius: 8px;
text-align: left;
}
.risk-cell.tone-rose {
background: #fef2f2;
color: #991b1b;
}
.risk-cell.tone-amber {
background: #fffbeb;
color: #92400e;
}
.risk-cell.tone-sky {
background: #f0f9ff;
color: #075985;
}
.risk-n {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.risk-lbl {
font-size: 11px;
margin-top: 4px;
opacity: 0.85;
}
.risk-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.risk-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
font-size: 12.5px;
}
.risk-row:last-child {
border-bottom: none;
}
.risk-title.tone-rose {
color: var(--el-color-danger);
}
.risk-title.tone-amber {
color: var(--el-color-warning);
}
.risk-sub {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { VNode } from 'vue';
import { computed, ref, watch } from 'vue';
import { ElTree } from 'element-plus';
import { objectContextDomainConfigs } from '@/constants/object-context';
import { useRouteStore } from '@/store/modules/route';
interface Props {
@@ -19,17 +21,28 @@ const routeStore = useRouteStore();
interface TreeNode {
key: string;
label: string;
/** routeStore.menus 同源的 SvgIconVNode */
icon?: VNode;
children?: TreeNode[];
isLeaf: boolean;
}
// 对象域入口project_list / product_list在 picker 视角下当顶级叶子,
// 它们的 children 是"进入某个对象后"的上下文页,不属于通用菜单。
const OBJECT_DOMAIN_ENTRY_KEYS = new Set(objectContextDomainConfigs.map(c => c.entryRouteKey));
function toTreeNodes(menus: any[]): TreeNode[] {
return menus.map(m => ({
key: m.key,
label: m.label as string,
isLeaf: !m.children || m.children.length === 0,
children: m.children ? toTreeNodes(m.children) : undefined
}));
return menus.map(m => {
const isDomainEntry = OBJECT_DOMAIN_ENTRY_KEYS.has(m.key);
const hasChildren = !isDomainEntry && m.children && m.children.length > 0;
return {
key: m.key,
label: m.label as string,
icon: m.icon,
isLeaf: !hasChildren,
children: hasChildren ? toTreeNodes(m.children) : undefined
};
});
}
const treeData = computed(() => toTreeNodes(routeStore.menus));
@@ -43,13 +56,14 @@ watch(
if (open) {
checkedKeys.value = [...props.initialSelected];
// 等抽屉 transition 后设置 checked
setTimeout(() => treeRef.value?.setCheckedKeys(props.initialSelected, true), 100);
setTimeout(() => treeRef.value?.setCheckedKeys(props.initialSelected, false), 100);
}
}
);
function handleConfirm() {
const allChecked = treeRef.value?.getCheckedKeys(true) ?? []; // leafOnly = true
// 父子联动后只取叶子节点(含对象域入口,因为它们 isLeaf=true
const allChecked = treeRef.value?.getCheckedKeys(true) ?? [];
emit(
'confirm',
allChecked.map(k => String(k))
@@ -67,17 +81,25 @@ function handleConfirm() {
@update:model-value="emit('update:modelValue', $event)"
>
<template #default>
<p class="hint">勾选你想加入快捷入口的菜单仅叶子节点可勾选</p>
<p class="hint">勾选加入快捷入口的菜单勾父级会自动选中其所有子菜单</p>
<ElTree
ref="treeRef"
:data="treeData"
node-key="key"
:props="{ label: 'label', children: 'children' }"
show-checkbox
check-strictly
default-expand-all
:check-on-click-node="false"
/>
>
<template #default="{ data }">
<span class="tree-node">
<ElIcon v-if="data.icon" class="tree-node__icon">
<component :is="data.icon" />
</ElIcon>
<span class="tree-node__label">{{ data.label }}</span>
</span>
</template>
</ElTree>
</template>
<template #footer>
<div class="footer">
@@ -94,6 +116,18 @@ function handleConfirm() {
font-size: 13px;
margin: 0 0 12px;
}
.tree-node {
display: inline-flex;
align-items: center;
gap: 6px;
}
.tree-node__icon {
color: var(--el-color-primary);
font-size: 15px;
}
.tree-node__label {
font-size: 13px;
}
.footer {
display: flex;
justify-content: flex-end;

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import type { VNode } from 'vue';
import { computed, ref } from 'vue';
import { objectContextDomainConfigs } from '@/constants/object-context';
import { useRouteStore } from '@/store/modules/route';
import { useWorkbenchStore } from '@/store/modules/workbench';
import { useRouterPush } from '@/hooks/common/router';
@@ -28,20 +30,27 @@ const { routerPushByKey } = useRouterPush();
interface FlatMenu {
key: string;
label: string;
icon?: string;
/** 来自 routeStore.menus 的 icon VNodeSvgIconVNode 渲染产物,与侧栏菜单同源) */
icon?: VNode;
}
// 与 picker 保持一致对象域入口project_list / product_list当叶子
// 其下的对象上下文页不进入快捷入口候选范围。
const OBJECT_DOMAIN_ENTRY_KEYS = new Set(objectContextDomainConfigs.map(c => c.entryRouteKey));
function flatten(menus: typeof routeStore.menus): FlatMenu[] {
const out: FlatMenu[] = [];
function walk(list: typeof menus) {
list.forEach((m: any) => {
if (m.children && m.children.length) {
const isDomainEntry = OBJECT_DOMAIN_ENTRY_KEYS.has(m.key);
const hasChildren = !isDomainEntry && m.children && m.children.length > 0;
if (hasChildren) {
walk(m.children);
} else {
out.push({
key: m.key,
label: m.label as string,
icon: m.i18nKey || m.icon || ''
icon: m.icon
});
}
});
@@ -90,8 +99,15 @@ function handleConfirm(keys: string[]) {
</div>
<div v-else class="shortcut-grid">
<button v-for="item in selected" :key="item.key" class="shortcut-item" @click="handleClick(item.key)">
<SvgIcon icon="mdi:link-variant" />
<span>{{ item.label }}</span>
<ElIcon v-if="item.icon" class="shortcut-item__icon">
<component :is="item.icon" />
</ElIcon>
<SvgIcon v-else icon="mdi:link-variant" class="shortcut-item__icon" />
<span class="shortcut-item__label">{{ item.label }}</span>
</button>
<button class="shortcut-item shortcut-item--add" title="添加快捷入口" @click="openPicker">
<SvgIcon icon="mdi:plus" />
<span>添加</span>
</button>
</div>
@@ -121,11 +137,37 @@ function handleConfirm(keys: string[]) {
transition: all 120ms;
}
.shortcut-item__icon {
font-size: 20px;
color: var(--el-color-primary);
transition: color 120ms;
}
.shortcut-item__label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shortcut-item:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.shortcut-item--add {
border-style: dashed;
border-color: var(--el-border-color);
background: transparent;
color: var(--el-text-color-secondary);
}
.shortcut-item--add:hover {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.shortcut-empty {
padding: 20px 0;
}

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getWorkbenchItemColor } from '../composables/use-workbench-colors';
import { type WorkbenchTeamLoadLevel, buildWorkbenchTeamLoadView } from '../homepage';
import { workbenchTeamLoadMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamLoad' });
@@ -10,24 +14,18 @@ interface Props {
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface LoadRow {
name: string;
inProgress: number;
}
const view = computed(() => buildWorkbenchTeamLoadView(workbenchTeamLoadMock));
const rows: LoadRow[] = [
{ name: '张三', inProgress: 5 },
{ name: '李四', inProgress: 3 },
{ name: '王五', inProgress: 7 },
{ name: '赵六', inProgress: 2 },
{ name: '钱七', inProgress: 5 }
];
const LEVEL_LABEL: Record<WorkbenchTeamLoadLevel, string> = {
high: '高负载',
mid: '中负载',
normal: '正常'
};
const MAX = 10;
function level(n: number): 'ok' | 'warn' | 'over' {
if (n >= 6) return 'over';
if (n >= 4) return 'warn';
return 'ok';
function urgentTooltip(dueSoon: number, overdue: number) {
if (dueSoon > 0 && overdue > 0) return `临期 ${dueSoon} · 逾期 ${overdue}`;
if (overdue > 0) return `逾期 ${overdue}`;
return `临期 ${dueSoon}`;
}
</script>
@@ -40,69 +38,243 @@ function level(n: number): 'ok' | 'warn' | 'over' {
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ul class="load-list">
<li v-for="row in rows" :key="row.name" class="load-item">
<span class="load-name">{{ row.name }}</span>
<div class="load-bar">
<div
class="load-bar-inner"
:class="`is-${level(row.inProgress)}`"
:style="{ width: `${(row.inProgress / MAX) * 100}%` }"
/>
<div class="tl-kpis">
<div class="tl-kpi">
<span class="tl-kpi__label">高负载</span>
<span class="tl-kpi__value" :class="{ 'is-danger': view.highCount > 0 }">
{{ view.highCount }}
<span class="tl-kpi__unit"></span>
</span>
</div>
<div class="tl-kpi">
<span class="tl-kpi__label">中负载</span>
<span class="tl-kpi__value" :class="{ 'is-warn': view.midCount > 0 }">
{{ view.midCount }}
<span class="tl-kpi__unit"></span>
</span>
</div>
<div class="tl-kpi">
<span class="tl-kpi__label">临期 + 逾期</span>
<span class="tl-kpi__value" :class="{ 'is-danger': view.urgentTotal > 0 }">
{{ view.urgentTotal }}
<span class="tl-kpi__unit"></span>
</span>
</div>
</div>
<ul class="tl-list">
<li v-for="m in view.members" :key="m.memberId" class="tl-row">
<span class="tl-row__dot" :class="`is-${m.level}`" :title="LEVEL_LABEL[m.level]" />
<span class="tl-row__name">{{ m.memberName }}</span>
<div class="tl-row__bar-wrap">
<div class="tl-row__bar" :style="{ width: `${m.barWidthPercent}%` }">
<ElTooltip
v-for="seg in m.segments"
:key="seg.key"
:content="`${seg.label} · ${seg.count} 个`"
placement="top"
>
<div
class="tl-row__seg"
:style="{
width: `${seg.widthPercent}%`,
background: getWorkbenchItemColor(seg.key, seg.kind)
}"
/>
</ElTooltip>
</div>
<span v-if="m.overflowExtra > 0" class="tl-row__overflow">+{{ m.overflowExtra }}</span>
</div>
<span class="load-n" :class="{ 'text-danger': level(row.inProgress) === 'over' }">
{{ row.inProgress }}{{ level(row.inProgress) === 'over' ? ' ⚠' : '' }}
<span class="tl-row__metrics">
<span class="tl-row__metric" :class="`is-${m.level}`">
<b>{{ m.inProgress }}</b>
进行
</span>
<span v-if="m.urgent > 0" class="tl-row__metric is-urgent">
<ElTooltip :content="urgentTooltip(m.dueSoon, m.overdue)" placement="top">
<span>
<b>{{ m.urgent }}</b>
临期
<SvgIcon v-if="m.overdue > 0" icon="mdi:alert" class="tl-row__warn-icon" />
</span>
</ElTooltip>
</span>
</span>
</li>
</ul>
<div class="load-hint">阈值 6 高负载 · 4 中负载</div>
<div class="tl-hint"> = 进行中 6 临期+逾期 2 · = 进行中 4 临期+逾期 1</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.load-list {
list-style: none;
margin: 0;
padding: 0;
}
.load-item {
.tl-kpis {
display: grid;
grid-template-columns: 60px 1fr 50px;
align-items: center;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 6px 0;
font-size: 13px;
margin-bottom: 12px;
}
.load-bar {
height: 6px;
border-radius: 3px;
background: var(--el-fill-color);
overflow: hidden;
.tl-kpi {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
min-width: 0;
}
.load-bar-inner {
height: 100%;
}
.load-bar-inner.is-ok {
background: var(--el-color-success);
}
.load-bar-inner.is-warn {
background: var(--el-color-warning);
}
.load-bar-inner.is-over {
background: var(--el-color-danger);
}
.load-n {
text-align: right;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.text-danger {
color: var(--el-color-danger);
font-weight: 600;
}
.load-hint {
margin-top: 8px;
.tl-kpi__label {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.tl-kpi__value {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.2;
}
.tl-kpi__value.is-danger {
color: var(--el-color-danger);
}
.tl-kpi__value.is-warn {
color: var(--el-color-warning);
}
.tl-kpi__unit {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
margin-left: 2px;
}
.tl-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
}
.tl-list::-webkit-scrollbar {
width: 6px;
}
.tl-list::-webkit-scrollbar-thumb {
background: var(--el-fill-color-darker);
border-radius: 3px;
}
.tl-list::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
.tl-row {
display: grid;
grid-template-columns: 10px 64px 1fr auto;
align-items: center;
gap: 10px;
padding: 7px 0;
font-size: 13px;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.tl-row:last-child {
border-bottom: none;
}
.tl-row__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-color-success);
}
.tl-row__dot.is-high {
background: var(--el-color-danger);
}
.tl-row__dot.is-mid {
background: var(--el-color-warning);
}
.tl-row__dot.is-normal {
background: var(--el-color-success);
}
.tl-row__name {
color: var(--el-text-color-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tl-row__bar-wrap {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tl-row__bar {
height: 8px;
border-radius: 4px;
background: var(--el-fill-color);
overflow: hidden;
display: flex;
min-width: 0;
transition: width 0.3s ease;
}
.tl-row__seg {
height: 100%;
transition: filter 0.15s ease;
cursor: default;
}
.tl-row__seg:hover {
filter: brightness(0.92);
}
.tl-row__seg + .tl-row__seg {
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.75);
}
.tl-row__overflow {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 8px;
background: var(--el-color-danger);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 1.4;
}
.tl-row__metrics {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.tl-row__metric b {
color: var(--el-text-color-primary);
font-weight: 600;
margin-right: 2px;
}
.tl-row__metric.is-high b {
color: var(--el-color-danger);
}
.tl-row__metric.is-mid b {
color: var(--el-color-warning);
}
.tl-row__metric.is-normal b {
color: var(--el-color-success);
}
.tl-row__metric.is-urgent {
color: var(--el-color-danger);
}
.tl-row__metric.is-urgent b {
color: var(--el-color-danger);
}
.tl-row__warn-icon {
vertical-align: -2px;
margin-left: 2px;
font-size: 12px;
color: var(--el-color-danger);
}
.tl-hint {
margin-top: 10px;
font-size: 11px;
color: var(--el-text-color-placeholder);
line-height: 1.5;
}
</style>

View File

@@ -1,182 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamTodo' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface KanbanTask {
id: string;
title: string;
priority: '高' | '中' | '低';
deadline: string;
overdue?: boolean;
}
interface MemberColumn {
name: string;
total: number;
warn?: boolean;
tasks: KanbanTask[];
more?: number;
}
const columns: MemberColumn[] = [
{
name: '张三',
total: 5,
tasks: [
{ id: 't1', title: '登录页 SSO 改造', priority: '高', deadline: '今日截止' },
{ id: 't2', title: '用户中心头像上传', priority: '中', deadline: '05-27' },
{ id: 't3', title: '登录日志埋点', priority: '低', deadline: '06-01' }
]
},
{
name: '李四',
total: 3,
tasks: [
{ id: 't4', title: '分片设计评审', priority: '中', deadline: '明日' },
{ id: 't5', title: '缓存穿透优化', priority: '高', deadline: '已逾期', overdue: true }
]
},
{
name: '王五',
total: 7,
warn: true,
tasks: [
{ id: 't6', title: '多币种支持开发', priority: '高', deadline: '05-26' },
{ id: 't7', title: '汇率服务接入', priority: '中', deadline: '05-28' }
],
more: 5
},
{
name: '赵六',
total: 2,
tasks: [
{ id: 't8', title: '风控规则配置化', priority: '中', deadline: '05-29' },
{ id: 't9', title: '规则引擎压测', priority: '低', deadline: '06-05' }
]
}
];
function priorityClass(p: KanbanTask['priority']) {
return ({ : 'tone-rose', : 'tone-amber', : 'tone-slate' } as const)[p];
}
</script>
<template>
<WorkbenchModuleCard
title="团队任务看板"
icon="mdi:view-column-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="kanban">
<div v-for="col in columns" :key="col.name" class="kanban-col">
<div class="kanban-h">
<span>{{ col.name }}</span>
<span class="kanban-total" :class="{ 'is-warn': col.warn }">{{ col.total }}{{ col.warn ? ' ⚠' : '' }}</span>
</div>
<div v-for="task in col.tasks" :key="task.id" class="kanban-task">
<div class="kanban-title">{{ task.title }}</div>
<div class="kanban-meta">
<span class="kanban-pri" :class="priorityClass(task.priority)">{{ task.priority }}</span>
<span :class="{ 'text-danger': task.overdue }">{{ task.deadline }}</span>
</div>
</div>
<div v-if="col.more" class="kanban-more">+{{ col.more }} </div>
</div>
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.kanban {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.kanban-col {
background: var(--el-fill-color-lighter);
border-radius: 6px;
padding: 8px;
min-height: 140px;
}
.kanban-h {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
font-weight: 600;
}
.kanban-total {
background: var(--el-bg-color);
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
}
.kanban-total.is-warn {
color: var(--el-color-danger);
font-weight: 700;
}
.kanban-task {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 5px;
padding: 6px;
margin-bottom: 6px;
font-size: 11px;
}
.kanban-title {
font-weight: 500;
margin-bottom: 4px;
}
.kanban-meta {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-text-color-secondary);
font-size: 10px;
}
.kanban-pri {
padding: 0 4px;
border-radius: 4px;
font-weight: 700;
}
.tone-rose {
background: #fee2e2;
color: #991b1b;
}
.tone-amber {
background: #fef3c7;
color: #92400e;
}
.tone-slate {
background: #f1f5f9;
color: #475569;
}
.text-danger {
color: var(--el-color-danger);
font-weight: 600;
}
.kanban-more {
font-size: 11px;
color: var(--el-color-danger);
text-align: center;
padding: 4px;
}
@media (width <= 1280px) {
.kanban {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -1,112 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTeamWorklog' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface MemberRow {
name: string;
hours: number;
}
const members: MemberRow[] = [
{ name: '张三', hours: 38 },
{ name: '李四', hours: 42 },
{ name: '王五', hours: 30 },
{ name: '赵六', hours: 48 },
{ name: '钱七', hours: 25 }
];
const maxHours = 48;
const avg = (members.reduce((s, m) => s + m.hours, 0) / members.length).toFixed(1);
const lowest = members.reduce((a, b) => (a.hours < b.hours ? a : b));
const highest = members.reduce((a, b) => (a.hours > b.hours ? a : b));
</script>
<template>
<WorkbenchModuleCard
title="团队工时分布"
icon="mdi:chart-bar"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="bars">
<div v-for="m in members" :key="m.name" class="bar-col">
<div class="bar-value">{{ m.hours }}h</div>
<div class="bar" :style="{ height: `${(m.hours / maxHours) * 100}%` }" />
</div>
</div>
<div class="bars-x">
<div v-for="m in members" :key="m.name">{{ m.name }}</div>
</div>
<div class="bars-hint">
平均 {{ avg }}h / ·
<span class="text-danger">{{ lowest.name }}</span>
工时偏低 ·
<span class="text-warn">{{ highest.name }}</span>
40h
</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.bars {
display: flex;
align-items: flex-end;
gap: 10px;
height: 120px;
padding: 18px 4px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
position: relative;
}
.bar-value {
position: absolute;
top: -16px;
font-size: 10px;
color: var(--el-text-color-secondary);
}
.bar {
width: 100%;
background: linear-gradient(180deg, var(--el-color-primary), var(--el-color-primary-light-7));
border-radius: 4px 4px 0 0;
min-height: 6px;
}
.bars-x {
display: flex;
gap: 10px;
margin-top: 4px;
}
.bars-x div {
flex: 1;
text-align: center;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.bars-hint {
margin-top: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.text-danger {
color: var(--el-color-danger);
}
.text-warn {
color: var(--el-color-warning);
}
</style>

View File

@@ -1,86 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTicketSla' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
const unclosed = 12;
const overtime = 3;
const willOvertime = 5;
const byPriority = { high: 4, mid: 6, low: 2 };
</script>
<template>
<WorkbenchModuleCard
title="工单 SLA 总览"
icon="mdi:timer-alert-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<ElAlert type="warning" :closable="false" class="pending-hint">
工单业务暂未上线当前为 mock 数据正式接口落地后接通
</ElAlert>
<div class="sla-grid">
<div class="sla-cell tone-rose">
<div class="sla-n">{{ unclosed }}</div>
<div class="sla-lbl">未关闭</div>
</div>
<div class="sla-cell tone-rose">
<div class="sla-n">{{ overtime }}</div>
<div class="sla-lbl">超时</div>
</div>
<div class="sla-cell tone-amber">
<div class="sla-n">{{ willOvertime }}</div>
<div class="sla-lbl">将超时</div>
</div>
</div>
<div class="sla-hint">按优先级 {{ byPriority.high }} · {{ byPriority.mid }} · {{ byPriority.low }}</div>
</WorkbenchModuleCard>
</template>
<style scoped>
.pending-hint {
margin-bottom: 10px;
}
.sla-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.sla-cell {
padding: 12px;
border-radius: 8px;
text-align: left;
}
.sla-cell.tone-rose {
background: #fef2f2;
color: #991b1b;
}
.sla-cell.tone-amber {
background: #fffbeb;
color: #92400e;
}
.sla-n {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.sla-lbl {
font-size: 11px;
margin-top: 4px;
opacity: 0.85;
}
.sla-hint {
margin-top: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -57,7 +57,7 @@ const mainTabs: Array<{ key: WorkbenchTodoMainTab; label: string }> = [
{ key: 'task', label: '任务' },
{ key: 'ticket', label: '工单' },
{ key: 'personal', label: '个人事项' },
{ key: 'review', label: '待审' }
{ key: 'approval', label: '待审' }
];
const deadlineFilters: Array<{ key: Exclude<WorkbenchTodoDeadlineFilter, null>; label: string }> = [
@@ -85,7 +85,7 @@ const tabCounts = computed(() => {
task: 0,
ticket: 0,
personal: 0,
review: 0
approval: 0
};
allItems.value.forEach(item => {
counts[item.category] += 1;
@@ -99,7 +99,7 @@ const tabOverdueCount = computed(() => {
task: 0,
ticket: 0,
personal: 0,
review: 0
approval: 0
};
allItems.value.forEach(item => {
if (!isWorkbenchTodoOverdue(item)) return;
@@ -144,6 +144,7 @@ watch([activeTab, activeDeadlineFilter, activeSort], () => {
function handleSelectTab(key: WorkbenchTodoMainTab) {
if (activeTab.value === key) return;
activeTab.value = key;
if (key === 'approval') activeDeadlineFilter.value = null;
activeDeadlineFilter.value = null;
if (key !== 'task' && activeSort.value === 'priority') {
activeSort.value = 'deadline';
@@ -209,7 +210,7 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</div>
<div class="workbench-todo__filters">
<div class="workbench-todo__filters-left">
<div v-if="activeTab !== 'approval'" class="workbench-todo__filters-left">
<button
v-for="filter in deadlineFilters"
:key="filter.key"
@@ -221,6 +222,7 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
{{ filter.label }}
</button>
</div>
<div v-else></div>
<ElDropdown trigger="click" placement="bottom-end" @command="handleSelectSort">
<span class="workbench-todo__sort">

View File

@@ -1,111 +0,0 @@
<script setup lang="ts">
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchWorklogReminder' });
interface Props {
editing?: boolean;
collapsed?: boolean;
}
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
interface DayRow {
dateLabel: string;
status: 'filled' | 'pending' | 'missing';
hours: number;
}
const today = '今日未填报';
const todayHint = '已经 14:30 了,记得填工时';
const recent: DayRow[] = [
{ dateLabel: '05-21', status: 'filled', hours: 8 },
{ dateLabel: '05-20', status: 'filled', hours: 7.5 },
{ dateLabel: '05-19', status: 'missing', hours: 0 }
];
function fillNow() {
window.$message?.info('跳转工时填报mock');
}
function fillMakeup(label: string) {
window.$message?.info(`补填 ${label} 工时mock`);
}
</script>
<template>
<WorkbenchModuleCard
title="工时填报提醒"
icon="mdi:timer-sand"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="cta">
<div class="cta-text">
<div class="cta-title">{{ today }}</div>
<div class="cta-hint">{{ todayHint }}</div>
</div>
<ElButton type="primary" size="small" @click="fillNow">立即填报</ElButton>
</div>
<ul class="recent">
<li v-for="row in recent" :key="row.dateLabel" class="recent-item">
<span class="recent-date">{{ row.dateLabel }}</span>
<span v-if="row.status === 'filled'" class="recent-hours">已填 {{ row.hours }}h</span>
<ElButton v-else size="small" type="danger" link @click="fillMakeup(row.dateLabel)">补填</ElButton>
</li>
</ul>
</WorkbenchModuleCard>
</template>
<style scoped>
.cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-dark-2));
color: #fff;
margin-bottom: 10px;
}
.cta-text {
min-width: 0;
}
.cta-title {
font-size: 14px;
font-weight: 700;
}
.cta-hint {
font-size: 12px;
opacity: 0.9;
margin-top: 2px;
}
.recent {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.recent-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 6px;
}
.recent-item:hover {
background: var(--el-fill-color-lighter);
}
.recent-date {
font-size: 13px;
}
.recent-hours {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>