feat(projects): 工作台小组件设计
This commit is contained in:
@@ -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>
|
||||
|
||||
920
src/components/custom/business-user-picker.vue
Normal file
920
src/components/custom/business-user-picker.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
2
src/typings/components.d.ts
vendored
2
src/typings/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null,
|
||||
defaultTab: 'worklog'
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '填报已更新',
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
34
src/views/workbench/composables/use-workbench-colors.ts
Normal file
34
src/views/workbench/composables/use-workbench-colors.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// 工作台跨 widget 共享的"项目色注册器"。
|
||||
// 同一个 projectKey 在不同 widget(C12 团队工时分布、C13 团队负载、D16 我的本周工时…)
|
||||
// 颜色保持一致,形成系统级"项目色码"。
|
||||
//
|
||||
// 分配策略:按首次访问顺序循环取 PROJECT_COLORS;personal/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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 {
|
||||
/** 唯一 key;project 用 projectId,personal/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 一个 series,data[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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
@@ -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()} 周`
|
||||
};
|
||||
});
|
||||
|
||||
// 公告 mock:banner 阶段本地维护,等公告中心接口落地再迁移至 mock.ts
|
||||
const allNotices: NoticeRow[] = [
|
||||
{ id: 'n1', title: '【运维】本周六 02:00-04:00 数据库主从切换', timeLabel: '2 天前' },
|
||||
{ id: 'n2', title: '【HR】Q2 OKR 复盘截止 06-05', timeLabel: '3 天前' },
|
||||
{ id: 'n3', title: '【流程】工单 SLA 新规则即将上线', timeLabel: '1 周前' },
|
||||
{ id: 'n4', title: '【系统】新版本 25.06 发布日程公告', timeLabel: '2 周前' },
|
||||
{ id: 'n5', title: '【行政】6 月端午节放假安排', timeLabel: '3 周前' },
|
||||
{ id: 'n6', title: '【安全】禁止使用未受控外部 AI 工具处理客户数据', timeLabel: '1 个月前' }
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 VNode(SvgIconVNode 渲染产物,与侧栏菜单同源) */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user