feat(projects): 1、增加空白页占位;2、调试已开发功能;

This commit is contained in:
2026-05-14 09:05:08 +08:00
parent f634d21d2a
commit ddd05f8c02
58 changed files with 7392 additions and 1325 deletions

View File

@@ -1,19 +0,0 @@
{
"permissions": {
"allow": [
"Bash(pnpm gen-route *)",
"Bash(pnpm typecheck *)",
"Bash(pnpm lint *)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(Remove-Item *)",
"PowerShell(pnpm typecheck *)",
"WebFetch(domain:www.wangeditor.com)",
"Bash(node *)",
"Bash(dir \"rdms-project-boot-*\")",
"Bash(git stash *)",
"Bash(pnpm eslint *)",
"Bash(Select-String -Pattern \"business-rich-text-editor|task-operate-dialog\")",
"Bash(powershell *)"
]
}
}

3
.gitignore vendored
View File

@@ -38,5 +38,8 @@ yarn.lock
/docs/* /docs/*
!/docs/frontend-page-resource-manifest.json !/docs/frontend-page-resource-manifest.json
# Claude
/.claude/*
# Temp # Temp
/codeTemp/* /codeTemp/*

View File

@@ -27,8 +27,13 @@ export function setupElegantRouter() {
onRouteMetaGen(routeName) { onRouteMetaGen(routeName) {
const key = routeName as RouteKey; const key = routeName as RouteKey;
const constantRoutes: RouteKey[] = ['login', '403', '404', '500']; const constantRoutes: RouteKey[] = ['login', '403', '404', '500', 'workbench'];
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = { const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
workbench: {
icon: 'mdi:view-dashboard-outline',
order: 1,
keepAlive: true
},
product: { product: {
icon: 'carbon:product', icon: 'carbon:product',
order: 4 order: 4
@@ -79,6 +84,73 @@ export function setupElegantRouter() {
hideInMenu: true, hideInMenu: true,
activeMenu: 'project_list' activeMenu: 'project_list'
}, },
ticket: {
icon: 'mdi:ticket-confirmation-outline',
order: 6
},
'ticket_my-submitted': {
icon: 'mdi:upload-outline',
order: 1,
keepAlive: true
},
'ticket_my-pending': {
icon: 'mdi:inbox-arrow-down-outline',
order: 2,
keepAlive: true
},
metrics: {
icon: 'mdi:chart-line',
order: 7
},
'metrics_project-progress': {
icon: 'mdi:progress-clock',
order: 1,
keepAlive: true
},
'metrics_member-efficiency': {
icon: 'mdi:account-multiple-check-outline',
order: 2,
keepAlive: true
},
metrics_worktime: {
icon: 'mdi:clock-time-five-outline',
order: 3,
keepAlive: true
},
'personal-center': {
icon: 'mdi:account-circle-outline',
order: 8
},
'personal-center_my-profile': {
icon: 'mdi:account-box-outline',
order: 0,
keepAlive: true
},
'personal-center_my-weekly': {
icon: 'mdi:calendar-week-outline',
order: 1,
keepAlive: true
},
'personal-center_my-monthly': {
icon: 'mdi:calendar-month-outline',
order: 2,
keepAlive: true
},
'personal-center_my-performance': {
icon: 'mdi:trophy-outline',
order: 3,
keepAlive: true
},
'personal-center_my-application': {
icon: 'mdi:file-document-outline',
order: 4,
keepAlive: true
},
'personal-center_pending-approval': {
icon: 'mdi:check-decagram-outline',
order: 5,
keepAlive: true
},
system: { system: {
icon: 'carbon:cloud-service-management', icon: 'carbon:cloud-service-management',
order: 9, order: 9,
@@ -110,6 +182,20 @@ export function setupElegantRouter() {
hideInMenu: true, hideInMenu: true,
roles: ['R_ADMIN'], roles: ['R_ADMIN'],
activeMenu: 'system_user' activeMenu: 'system_user'
},
infra: {
icon: 'ep:monitor',
order: 20
},
'infra_state-machine': {
icon: 'mdi:state-machine',
order: 1,
keepAlive: true
},
'infra_rd-code': {
icon: 'mdi:identifier',
order: 2,
keepAlive: true
} }
}; };

View File

@@ -1,13 +1,46 @@
{ {
"generatedAt": "2026-04-29T08:18:14.397Z", "generatedAt": "2026-05-13T10:54:08.684Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.", "description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": { "rules": {
"directoryComponent": "layout.base", "directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>", "pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>" "singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
}, },
"total": 8, "total": 21,
"items": [ "items": [
{
"name": "workbench",
"path": "/workbench",
"component": "layout.base$view.workbench",
"title": "workbench",
"routeTitle": "workbench",
"i18nKey": "route.workbench",
"icon": "mdi:view-dashboard-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "workbench",
"i18nKey": "route.workbench",
"icon": "mdi:view-dashboard-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": null,
"pageType": "single",
"source": "generated"
},
{ {
"name": "product_list", "name": "product_list",
"path": "/product/list", "path": "/product/list",
@@ -74,6 +107,336 @@
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
}, },
{
"name": "ticket_my-submitted",
"path": "/ticket/my-submitted",
"component": "view.ticket_my-submitted",
"title": "ticket_my-submitted",
"routeTitle": "ticket_my-submitted",
"i18nKey": "route.ticket_my-submitted",
"icon": "mdi:upload-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "ticket_my-submitted",
"i18nKey": "route.ticket_my-submitted",
"icon": "mdi:upload-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "ticket",
"pageType": "leaf",
"source": "generated"
},
{
"name": "ticket_my-pending",
"path": "/ticket/my-pending",
"component": "view.ticket_my-pending",
"title": "ticket_my-pending",
"routeTitle": "ticket_my-pending",
"i18nKey": "route.ticket_my-pending",
"icon": "mdi:inbox-arrow-down-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "ticket_my-pending",
"i18nKey": "route.ticket_my-pending",
"icon": "mdi:inbox-arrow-down-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "ticket",
"pageType": "leaf",
"source": "generated"
},
{
"name": "metrics_project-progress",
"path": "/metrics/project-progress",
"component": "view.metrics_project-progress",
"title": "metrics_project-progress",
"routeTitle": "metrics_project-progress",
"i18nKey": "route.metrics_project-progress",
"icon": "mdi:progress-clock",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "metrics_project-progress",
"i18nKey": "route.metrics_project-progress",
"icon": "mdi:progress-clock",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "metrics",
"pageType": "leaf",
"source": "generated"
},
{
"name": "metrics_member-efficiency",
"path": "/metrics/member-efficiency",
"component": "view.metrics_member-efficiency",
"title": "metrics_member-efficiency",
"routeTitle": "metrics_member-efficiency",
"i18nKey": "route.metrics_member-efficiency",
"icon": "mdi:account-multiple-check-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "metrics_member-efficiency",
"i18nKey": "route.metrics_member-efficiency",
"icon": "mdi:account-multiple-check-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "metrics",
"pageType": "leaf",
"source": "generated"
},
{
"name": "metrics_worktime",
"path": "/metrics/worktime",
"component": "view.metrics_worktime",
"title": "metrics_worktime",
"routeTitle": "metrics_worktime",
"i18nKey": "route.metrics_worktime",
"icon": "mdi:clock-time-five-outline",
"localIcon": null,
"order": 3,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "metrics_worktime",
"i18nKey": "route.metrics_worktime",
"icon": "mdi:clock-time-five-outline",
"localIcon": null,
"order": 3,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "metrics",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-weekly",
"path": "/personal-center/my-weekly",
"component": "view.personal-center_my-weekly",
"title": "personal-center_my-weekly",
"routeTitle": "personal-center_my-weekly",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-outline",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-weekly",
"i18nKey": "route.personal-center_my-weekly",
"icon": "mdi:calendar-week-outline",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-monthly",
"path": "/personal-center/my-monthly",
"component": "view.personal-center_my-monthly",
"title": "personal-center_my-monthly",
"routeTitle": "personal-center_my-monthly",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-monthly",
"i18nKey": "route.personal-center_my-monthly",
"icon": "mdi:calendar-month-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-performance",
"path": "/personal-center/my-performance",
"component": "view.personal-center_my-performance",
"title": "personal-center_my-performance",
"routeTitle": "personal-center_my-performance",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
"order": 3,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-performance",
"i18nKey": "route.personal-center_my-performance",
"icon": "mdi:trophy-outline",
"localIcon": null,
"order": 3,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-application",
"path": "/personal-center/my-application",
"component": "view.personal-center_my-application",
"title": "personal-center_my-application",
"routeTitle": "personal-center_my-application",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 4,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_my-application",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 4,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_pending-approval",
"path": "/personal-center/pending-approval",
"component": "view.personal-center_pending-approval",
"title": "personal-center_pending-approval",
"routeTitle": "personal-center_pending-approval",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 5,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "personal-center_pending-approval",
"i18nKey": "route.personal-center_pending-approval",
"icon": "mdi:check-decagram-outline",
"localIcon": null,
"order": 5,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{ {
"name": "system_user", "name": "system_user",
"path": "/system/user", "path": "/system/user",
@@ -271,6 +634,72 @@
"parentName": "system", "parentName": "system",
"pageType": "leaf", "pageType": "leaf",
"source": "generated" "source": "generated"
},
{
"name": "infra_state-machine",
"path": "/infra/state-machine",
"component": "view.infra_state-machine",
"title": "infra_state-machine",
"routeTitle": "infra_state-machine",
"i18nKey": "route.infra_state-machine",
"icon": "mdi:state-machine",
"localIcon": null,
"order": 1,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "infra_state-machine",
"i18nKey": "route.infra_state-machine",
"icon": "mdi:state-machine",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "infra",
"pageType": "leaf",
"source": "generated"
},
{
"name": "infra_rd-code",
"path": "/infra/rd-code",
"component": "view.infra_rd-code",
"title": "infra_rd-code",
"routeTitle": "infra_rd-code",
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "infra_rd-code",
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "infra",
"pageType": "leaf",
"source": "generated"
} }
] ]
} }

View File

@@ -2,6 +2,13 @@
import { $t } from '@/locales'; import { $t } from '@/locales';
defineOptions({ name: 'LookForward' }); defineOptions({ name: 'LookForward' });
interface Props {
title?: string;
subtitle?: string;
}
defineProps<Props>();
</script> </script>
<template> <template>
@@ -10,7 +17,10 @@ defineOptions({ name: 'LookForward' });
<SvgIcon local-icon="expectation" /> <SvgIcon local-icon="expectation" />
</div> </div>
<slot> <slot>
<h3 class="text-28px text-primary font-500">{{ $t('common.lookForward') }}</h3> <h3 class="text-28px text-primary font-500">{{ title ?? $t('common.lookForward') }}</h3>
</slot>
<slot name="subtitle">
<p v-if="subtitle" class="text-14px text-base-text op-65">{{ subtitle }}</p>
</slot> </slot>
</div> </div>
</template> </template>

View File

@@ -18,7 +18,7 @@ function loginOrRegister() {
toLogin(); toLogin();
} }
type DropdownKey = 'user-center' | 'logout'; type DropdownKey = 'personal-center_my-profile' | 'logout';
type DropdownOption = { type DropdownOption = {
key: DropdownKey; key: DropdownKey;
@@ -29,8 +29,8 @@ type DropdownOption = {
const options = computed(() => { const options = computed(() => {
const opts: DropdownOption[] = [ const opts: DropdownOption[] = [
{ {
label: $t('common.userCenter'), label: $t('common.myProfile'),
key: 'user-center', key: 'personal-center_my-profile',
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 }) icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
}, },
{ {

View File

@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
trigger: 'Trigger', trigger: 'Trigger',
update: 'Update', update: 'Update',
updateSuccess: 'Update Success', updateSuccess: 'Update Success',
userCenter: 'User Center', myProfile: 'My Profile',
yesOrNo: { yesOrNo: {
yes: 'Yes', yes: 'Yes',
no: 'No' no: 'No'
@@ -158,7 +158,24 @@ const local: App.I18n.Schema = {
404: 'Page Not Found', 404: 'Page Not Found',
500: 'Server Error', 500: 'Server Error',
'iframe-page': 'Iframe', 'iframe-page': 'Iframe',
'user-center': 'User Center', workbench: 'Workbench',
ticket: 'Ticket',
'ticket_my-submitted': 'My Submitted',
'ticket_my-pending': 'My Pending',
metrics: 'Metrics',
'metrics_project-progress': 'Project Progress',
'metrics_member-efficiency': 'Member Efficiency',
metrics_worktime: 'Worktime',
'personal-center': 'Personal Center',
'personal-center_my-profile': 'My Profile',
'personal-center_my-weekly': 'My Weekly Report',
'personal-center_my-monthly': 'My Monthly Report',
'personal-center_my-performance': 'My Performance',
'personal-center_my-application': 'My Application',
'personal-center_pending-approval': 'Pending Approval',
infra: 'Infra',
'infra_state-machine': 'State Machine',
'infra_rd-code': 'R&D Code',
function: 'System Function', function: 'System Function',
function_tab: 'Tab', function_tab: 'Tab',
'function_multi-tab': 'Multi Tab', 'function_multi-tab': 'Multi Tab',
@@ -495,6 +512,7 @@ const local: App.I18n.Schema = {
orgType: { orgType: {
company: 'Company', company: 'Company',
dept: 'Department', dept: 'Department',
function: 'Functional Department',
direction: 'Direction', direction: 'Direction',
team: 'Team' team: 'Team'
}, },

View File

@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
trigger: '触发', trigger: '触发',
update: '更新', update: '更新',
updateSuccess: '更新成功', updateSuccess: '更新成功',
userCenter: '个人中心', myProfile: '个人信息',
yesOrNo: { yesOrNo: {
yes: '是', yes: '是',
no: '否' no: '否'
@@ -158,7 +158,24 @@ const local: App.I18n.Schema = {
404: '页面不存在', 404: '页面不存在',
500: '服务器错误', 500: '服务器错误',
'iframe-page': '外链页面', 'iframe-page': '外链页面',
'user-center': '个人中心', workbench: '工作台',
ticket: '工单',
'ticket_my-submitted': '我提交的工单',
'ticket_my-pending': '待我处理的工单',
metrics: '效能度量',
'metrics_project-progress': '项目进度',
'metrics_member-efficiency': '员工能效',
metrics_worktime: '工时统计',
'personal-center': '个人中心',
'personal-center_my-profile': '个人信息',
'personal-center_my-weekly': '我的周报',
'personal-center_my-monthly': '我的月报',
'personal-center_my-performance': '我的绩效',
'personal-center_my-application': '我的申请',
'personal-center_pending-approval': '待我审批',
infra: '基础设施',
'infra_state-machine': '状态机管理',
'infra_rd-code': '研发令号',
function: '系统功能', function: '系统功能',
function_tab: '标签页', function_tab: '标签页',
'function_multi-tab': '多标签页', 'function_multi-tab': '多标签页',
@@ -491,6 +508,7 @@ const local: App.I18n.Schema = {
orgType: { orgType: {
company: '公司', company: '公司',
dept: '部门', dept: '部门',
function: '职能部门',
direction: '方向', direction: '方向',
team: '团队' team: '团队'
}, },

View File

@@ -28,6 +28,17 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"function_super-page": () => import("@/views/function/super-page/index.vue"), "function_super-page": () => import("@/views/function/super-page/index.vue"),
function_tab: () => import("@/views/function/tab/index.vue"), function_tab: () => import("@/views/function/tab/index.vue"),
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"), "function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"), plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"), plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"), plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
@@ -63,5 +74,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
"system_user-detail": () => import("@/views/system/user-detail/[id].vue"), "system_user-detail": () => import("@/views/system/user-detail/[id].vue"),
"system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"), "system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"),
system_user: () => import("@/views/system/user/index.vue"), system_user: () => import("@/views/system/user/index.vue"),
"user-center": () => import("@/views/user-center/index.vue"), "ticket_my-pending": () => import("@/views/ticket/my-pending/index.vue"),
"ticket_my-submitted": () => import("@/views/ticket/my-submitted/index.vue"),
workbench: () => import("@/views/workbench/index.vue"),
}; };

View File

@@ -170,6 +170,43 @@ export const generatedRoutes: GeneratedRoute[] = [
keepAlive: true keepAlive: true
} }
}, },
{
name: 'infra',
path: '/infra',
component: 'layout.base',
meta: {
title: 'infra',
i18nKey: 'route.infra',
icon: 'ep:monitor',
order: 20
},
children: [
{
name: 'infra_rd-code',
path: '/infra/rd-code',
component: 'view.infra_rd-code',
meta: {
title: 'infra_rd-code',
i18nKey: 'route.infra_rd-code',
icon: 'mdi:identifier',
order: 2,
keepAlive: true
}
},
{
name: 'infra_state-machine',
path: '/infra/state-machine',
component: 'view.infra_state-machine',
meta: {
title: 'infra_state-machine',
i18nKey: 'route.infra_state-machine',
icon: 'mdi:state-machine',
order: 1,
keepAlive: true
}
}
]
},
{ {
name: 'login', name: 'login',
path: '/login/:module(pwd-login|reset-pwd)?', path: '/login/:module(pwd-login|reset-pwd)?',
@@ -182,6 +219,140 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true hideInMenu: true
} }
}, },
{
name: 'metrics',
path: '/metrics',
component: 'layout.base',
meta: {
title: 'metrics',
i18nKey: 'route.metrics',
icon: 'mdi:chart-line',
order: 7
},
children: [
{
name: 'metrics_member-efficiency',
path: '/metrics/member-efficiency',
component: 'view.metrics_member-efficiency',
meta: {
title: 'metrics_member-efficiency',
i18nKey: 'route.metrics_member-efficiency',
icon: 'mdi:account-multiple-check-outline',
order: 2,
keepAlive: true
}
},
{
name: 'metrics_project-progress',
path: '/metrics/project-progress',
component: 'view.metrics_project-progress',
meta: {
title: 'metrics_project-progress',
i18nKey: 'route.metrics_project-progress',
icon: 'mdi:progress-clock',
order: 1,
keepAlive: true
}
},
{
name: 'metrics_worktime',
path: '/metrics/worktime',
component: 'view.metrics_worktime',
meta: {
title: 'metrics_worktime',
i18nKey: 'route.metrics_worktime',
icon: 'mdi:clock-time-five-outline',
order: 3,
keepAlive: true
}
}
]
},
{
name: 'personal-center',
path: '/personal-center',
component: 'layout.base',
meta: {
title: 'personal-center',
i18nKey: 'route.personal-center',
icon: 'mdi:account-circle-outline',
order: 8
},
children: [
{
name: 'personal-center_my-application',
path: '/personal-center/my-application',
component: 'view.personal-center_my-application',
meta: {
title: 'personal-center_my-application',
i18nKey: 'route.personal-center_my-application',
icon: 'mdi:file-document-outline',
order: 4,
keepAlive: true
}
},
{
name: 'personal-center_my-monthly',
path: '/personal-center/my-monthly',
component: 'view.personal-center_my-monthly',
meta: {
title: 'personal-center_my-monthly',
i18nKey: 'route.personal-center_my-monthly',
icon: 'mdi:calendar-month-outline',
order: 2,
keepAlive: true
}
},
{
name: 'personal-center_my-performance',
path: '/personal-center/my-performance',
component: 'view.personal-center_my-performance',
meta: {
title: 'personal-center_my-performance',
i18nKey: 'route.personal-center_my-performance',
icon: 'mdi:trophy-outline',
order: 3,
keepAlive: true
}
},
{
name: 'personal-center_my-profile',
path: '/personal-center/my-profile',
component: 'view.personal-center_my-profile',
meta: {
title: 'personal-center_my-profile',
i18nKey: 'route.personal-center_my-profile',
icon: 'mdi:account-box-outline',
order: 0,
keepAlive: true
}
},
{
name: 'personal-center_my-weekly',
path: '/personal-center/my-weekly',
component: 'view.personal-center_my-weekly',
meta: {
title: 'personal-center_my-weekly',
i18nKey: 'route.personal-center_my-weekly',
icon: 'mdi:calendar-week-outline',
order: 1,
keepAlive: true
}
},
{
name: 'personal-center_pending-approval',
path: '/personal-center/pending-approval',
component: 'view.personal-center_pending-approval',
meta: {
title: 'personal-center_pending-approval',
i18nKey: 'route.personal-center_pending-approval',
icon: 'mdi:check-decagram-outline',
order: 5,
keepAlive: true
}
}
]
},
{ {
name: 'plugin', name: 'plugin',
path: '/plugin', path: '/plugin',
@@ -664,13 +835,53 @@ export const generatedRoutes: GeneratedRoute[] = [
] ]
}, },
{ {
name: 'user-center', name: 'ticket',
path: '/user-center', path: '/ticket',
component: 'layout.base$view.user-center', component: 'layout.base',
meta: { meta: {
title: 'user-center', title: 'ticket',
i18nKey: 'route.user-center', i18nKey: 'route.ticket',
hideInMenu: true icon: 'mdi:ticket-confirmation-outline',
order: 6
},
children: [
{
name: 'ticket_my-pending',
path: '/ticket/my-pending',
component: 'view.ticket_my-pending',
meta: {
title: 'ticket_my-pending',
i18nKey: 'route.ticket_my-pending',
icon: 'mdi:inbox-arrow-down-outline',
order: 2,
keepAlive: true
}
},
{
name: 'ticket_my-submitted',
path: '/ticket/my-submitted',
component: 'view.ticket_my-submitted',
meta: {
title: 'ticket_my-submitted',
i18nKey: 'route.ticket_my-submitted',
icon: 'mdi:upload-outline',
order: 1,
keepAlive: true
}
}
]
},
{
name: 'workbench',
path: '/workbench',
component: 'layout.base$view.workbench',
meta: {
title: 'workbench',
i18nKey: 'route.workbench',
icon: 'mdi:view-dashboard-outline',
order: 1,
keepAlive: true,
constant: true
} }
} }
]; ];

View File

@@ -181,7 +181,21 @@ const routeMap: RouteMap = {
"function_tab": "/function/tab", "function_tab": "/function/tab",
"function_toggle-auth": "/function/toggle-auth", "function_toggle-auth": "/function/toggle-auth",
"iframe-page": "/iframe-page/:url", "iframe-page": "/iframe-page/:url",
"infra": "/infra",
"infra_rd-code": "/infra/rd-code",
"infra_state-machine": "/infra/state-machine",
"login": "/login/:module(pwd-login|reset-pwd)?", "login": "/login/:module(pwd-login|reset-pwd)?",
"metrics": "/metrics",
"metrics_member-efficiency": "/metrics/member-efficiency",
"metrics_project-progress": "/metrics/project-progress",
"metrics_worktime": "/metrics/worktime",
"personal-center": "/personal-center",
"personal-center_my-application": "/personal-center/my-application",
"personal-center_my-monthly": "/personal-center/my-monthly",
"personal-center_my-performance": "/personal-center/my-performance",
"personal-center_my-profile": "/personal-center/my-profile",
"personal-center_my-weekly": "/personal-center/my-weekly",
"personal-center_pending-approval": "/personal-center/pending-approval",
"plugin": "/plugin", "plugin": "/plugin",
"plugin_barcode": "/plugin/barcode", "plugin_barcode": "/plugin/barcode",
"plugin_charts": "/plugin/charts", "plugin_charts": "/plugin/charts",
@@ -226,7 +240,10 @@ const routeMap: RouteMap = {
"system_user": "/system/user", "system_user": "/system/user",
"system_user-detail": "/system/user-detail/:id", "system_user-detail": "/system/user-detail/:id",
"system_user-management-relation": "/system/user-management-relation", "system_user-management-relation": "/system/user-management-relation",
"user-center": "/user-center" "ticket": "/ticket",
"ticket_my-pending": "/ticket/my-pending",
"ticket_my-submitted": "/ticket/my-submitted",
"workbench": "/workbench"
}; };
/** /**

View File

@@ -48,6 +48,16 @@ type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
type ProjectExecutionPageResponse = Api.Project.PageResult<ProjectExecutionResponse>; type ProjectExecutionPageResponse = Api.Project.PageResult<ProjectExecutionResponse>;
type ProjectTaskPageResponse = Api.Project.PageResult<ProjectTaskResponse>; type ProjectTaskPageResponse = Api.Project.PageResult<ProjectTaskResponse>;
type StatusBoardResponse = Api.Project.StatusBoard; type StatusBoardResponse = Api.Project.StatusBoard;
type ProjectTaskBoardPageResponse = {
items: Array<{
statusCode: string;
statusName: string;
sort: number;
terminal?: boolean;
list: ProjectTaskResponse[];
total: number;
}>;
};
type ProjectContextResponse = Omit<Api.Project.ProjectContext, 'currentProject' | 'navs'> & { type ProjectContextResponse = Omit<Api.Project.ProjectContext, 'currentProject' | 'navs'> & {
currentProject: Omit<Api.Project.ProjectContext['currentProject'], 'id'> & { id: string | number }; currentProject: Omit<Api.Project.ProjectContext['currentProject'], 'id'> & { id: string | number };
@@ -523,6 +533,32 @@ export function fetchGetProjectTaskStatusBoard(
}); });
} }
/**
* 任务看板按状态分组的分页接口。
*
* 看板模式专用:一次请求拿到所有列(或指定列)的首屏 + 总数,替代"5 列 5 次 page"的旧方式。
* 列内向下滚续页时再传 `statusCode=[X]&pageNo=N+1` 单列查询。
*/
export async function fetchGetProjectTaskBoardPage(
projectId: string,
executionId: string,
params?: Api.Project.ProjectTaskBoardPageParams
) {
const result = await request<ProjectTaskBoardPageResponse>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/board-page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProjectTaskBoardPageResponse>, data => ({
items: data.items.map(item => ({
...item,
list: item.list.map(normalizeProjectTask)
}))
}));
}
/** 获取项目任务详情 */ /** 获取项目任务详情 */
export async function fetchGetProjectTask(projectId: string, executionId: string, taskId: string) { export async function fetchGetProjectTask(projectId: string, executionId: string, taskId: string) {
const result = await request<ProjectTaskResponse>({ const result = await request<ProjectTaskResponse>({

View File

@@ -0,0 +1,8 @@
// 工作台聚合接口尚未开通,当前页面使用 src/views/workbench/mock.ts 的本地假数据。
// 接口契约确认后,在此处补:
// - fetchGetWorkbenchSummary (Banner 摘要 + KPI)
// - fetchGetWorkbenchTodos (我的待办)
// - fetchGetWorkbenchActivity (最近动态)
// - fetchGetWorkbenchProjects (我参与的项目)
// 全部走 src/service/request/index.ts 的统一实例,并保持 ID 字符串口径。
export {};

View File

@@ -242,7 +242,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
/** 统一处理常量路由和权限路由 */ /** 统一处理常量路由和权限路由 */
async function handleConstantAndAuthRoutes() { async function handleConstantAndAuthRoutes() {
const { getAuthVueRoutes } = await loadRouteModule(); const { getAuthVueRoutes } = await loadRouteModule();
const allRoutes = [...constantRoutes.value, ...authRoutes.value]; // 常量路由优先:动态权限路由中与常量路由 name 重复的项剔除,避免菜单出现重复入口(如 workbench
const constantRouteNames = new Set(constantRoutes.value.map(route => route.name));
const dedupedAuthRoutes = authRoutes.value.filter(route => !constantRouteNames.has(route.name));
const allRoutes = [...constantRoutes.value, ...dedupedAuthRoutes];
const sortRoutes = sortRoutesByOrder(allRoutes); const sortRoutes = sortRoutesByOrder(allRoutes);

View File

@@ -317,6 +317,36 @@ declare namespace Api {
updateTime: string[]; updateTime: string[];
}>; }>;
/**
* 任务看板按状态分组的分页入参。
*
* - `statusCode` 缺省 → 返回该执行下任务状态字典中的全部状态(即使该状态下当前没有任务,也要回该列、`total=0`、`list=[]`)。
* - 传入数组 → 只返回这些状态的列。
* - `pageNo` / `pageSize` 应用到所有返回的状态(同一页码下各状态各自分页),前端不需要"每列独立 pageNo"。
*/
type ProjectTaskBoardPageParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
statusCode: string[];
keyword: string;
parentTaskId: string;
ownerId: string;
updateTime: string[];
}
>;
interface ProjectTaskBoardColumn {
statusCode: string;
statusName: string;
sort: number;
terminal?: boolean;
list: ProjectTask[];
total: number;
}
interface ProjectTaskBoardPage {
items: ProjectTaskBoardColumn[];
}
interface SaveProjectTaskParams { interface SaveProjectTaskParams {
parentTaskId: string | null; parentTaskId: string | null;
taskTitle: string; taskTitle: string;

View File

@@ -69,7 +69,7 @@ declare namespace Api {
roleCode: string; roleCode: string;
}; };
type DeptOrgType = 'company' | 'dept' | 'direction' | 'team'; type DeptOrgType = 'company' | 'dept' | 'function' | 'direction' | 'team';
interface Dept { interface Dept {
id: number; id: number;

View File

@@ -333,7 +333,7 @@ declare namespace App {
trigger: string; trigger: string;
update: string; update: string;
updateSuccess: string; updateSuccess: string;
userCenter: string; myProfile: string;
yesOrNo: { yesOrNo: {
yes: string; yes: string;
no: string; no: string;
@@ -684,6 +684,7 @@ declare namespace App {
orgType: { orgType: {
company: string; company: string;
dept: string; dept: string;
function: string;
direction: string; direction: string;
team: string; team: string;
}; };

View File

@@ -35,7 +35,21 @@ declare module "@elegant-router/types" {
"function_tab": "/function/tab"; "function_tab": "/function/tab";
"function_toggle-auth": "/function/toggle-auth"; "function_toggle-auth": "/function/toggle-auth";
"iframe-page": "/iframe-page/:url"; "iframe-page": "/iframe-page/:url";
"infra": "/infra";
"infra_rd-code": "/infra/rd-code";
"infra_state-machine": "/infra/state-machine";
"login": "/login/:module(pwd-login|reset-pwd)?"; "login": "/login/:module(pwd-login|reset-pwd)?";
"metrics": "/metrics";
"metrics_member-efficiency": "/metrics/member-efficiency";
"metrics_project-progress": "/metrics/project-progress";
"metrics_worktime": "/metrics/worktime";
"personal-center": "/personal-center";
"personal-center_my-application": "/personal-center/my-application";
"personal-center_my-monthly": "/personal-center/my-monthly";
"personal-center_my-performance": "/personal-center/my-performance";
"personal-center_my-profile": "/personal-center/my-profile";
"personal-center_my-weekly": "/personal-center/my-weekly";
"personal-center_pending-approval": "/personal-center/pending-approval";
"plugin": "/plugin"; "plugin": "/plugin";
"plugin_barcode": "/plugin/barcode"; "plugin_barcode": "/plugin/barcode";
"plugin_charts": "/plugin/charts"; "plugin_charts": "/plugin/charts";
@@ -80,7 +94,10 @@ declare module "@elegant-router/types" {
"system_user": "/system/user"; "system_user": "/system/user";
"system_user-detail": "/system/user-detail/:id"; "system_user-detail": "/system/user-detail/:id";
"system_user-management-relation": "/system/user-management-relation"; "system_user-management-relation": "/system/user-management-relation";
"user-center": "/user-center"; "ticket": "/ticket";
"ticket_my-pending": "/ticket/my-pending";
"ticket_my-submitted": "/ticket/my-submitted";
"workbench": "/workbench";
}; };
/** /**
@@ -121,12 +138,16 @@ declare module "@elegant-router/types" {
| "500" | "500"
| "function" | "function"
| "iframe-page" | "iframe-page"
| "infra"
| "login" | "login"
| "metrics"
| "personal-center"
| "plugin" | "plugin"
| "product" | "product"
| "project" | "project"
| "system" | "system"
| "user-center" | "ticket"
| "workbench"
>; >;
/** /**
@@ -157,6 +178,17 @@ declare module "@elegant-router/types" {
| "function_super-page" | "function_super-page"
| "function_tab" | "function_tab"
| "function_toggle-auth" | "function_toggle-auth"
| "infra_rd-code"
| "infra_state-machine"
| "metrics_member-efficiency"
| "metrics_project-progress"
| "metrics_worktime"
| "personal-center_my-application"
| "personal-center_my-monthly"
| "personal-center_my-performance"
| "personal-center_my-profile"
| "personal-center_my-weekly"
| "personal-center_pending-approval"
| "plugin_barcode" | "plugin_barcode"
| "plugin_charts_antv" | "plugin_charts_antv"
| "plugin_charts_echarts" | "plugin_charts_echarts"
@@ -192,7 +224,9 @@ declare module "@elegant-router/types" {
| "system_user-detail" | "system_user-detail"
| "system_user-management-relation" | "system_user-management-relation"
| "system_user" | "system_user"
| "user-center" | "ticket_my-pending"
| "ticket_my-submitted"
| "workbench"
>; >;
/** /**

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="研发令号" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="状态机管理" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="员工能效" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="项目进度" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="工时统计" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的申请" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的月报" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的绩效" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="个人信息" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我的周报" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="待我审批" subtitle="功能建设中,敬请期待" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,767 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useCurrentProduct } from '../shared/use-current-product';
import ProductActivityTimelinePanel from './modules/product-activity-timeline-panel.vue';
import {
buildProductHomepageBanner,
buildRequirementPoolRecentChanges,
buildRequirementPoolSummary,
getProductHomepageExtensionModules
} from './homepage';
import { productHomepageExtensionMock, productRequirementPoolMock } from './mock';
defineOptions({ name: 'ProductDashboard' });
const { currentObjectId } = useCurrentProduct();
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const pageLoading = ref(false);
const productDetail = ref<Api.Product.Product | null>(null);
const settings = ref<Api.Product.ProductSettings | null>(null);
const members = ref<Api.Product.ProductMember[]>([]);
const latestActivityTime = ref('');
const requirementPoolSummary = computed(() => buildRequirementPoolSummary(productRequirementPoolMock.summary));
const requirementPoolRecentChanges = computed(() =>
buildRequirementPoolRecentChanges(productRequirementPoolMock.recentChanges)
);
const homepageBanner = computed(() =>
buildProductHomepageBanner({
product: productDetail.value,
settings: settings.value,
members: members.value,
requirementSummary: requirementPoolSummary.value,
latestActivityTime: latestActivityTime.value
})
);
const extensionModules = computed(() => getProductHomepageExtensionModules(productHomepageExtensionMock));
const directionLabel = computed(() => getDirectionDictLabel(homepageBanner.value.identity.directionCode, '--'));
const bannerFacts = computed(() => {
const [managerFact, roleFact] = homepageBanner.value.identity.facts;
return [
{
label: '产品方向',
value: directionLabel.value,
fullWidth: false
},
{
label: managerFact?.label || '产品经理',
value: managerFact?.value || '--',
fullWidth: false
},
{
label: roleFact?.label || '角色摘要',
value: roleFact?.value || '--',
fullWidth: true
}
];
});
const bannerStatusClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode ? `product-homepage-banner--${statusCode}` : 'product-homepage-banner--default';
});
const bannerStatusWordClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode
? `product-homepage-banner__status-word--${statusCode}`
: 'product-homepage-banner__status-word--default';
});
function handleLatestActivityTimeChange(value: string) {
latestActivityTime.value = value;
}
async function loadDashboardData(objectId: string) {
pageLoading.value = true;
try {
const [productResult, settingsResult, membersResult] = await Promise.all([
fetchGetProduct(objectId),
fetchGetProductSettings(objectId),
fetchGetProductMembers(objectId)
]);
productDetail.value = productResult.error ? null : productResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
} finally {
pageLoading.value = false;
}
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
productDetail.value = null;
settings.value = null;
members.value = [];
latestActivityTime.value = '';
return;
}
await loadDashboardData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="product-homepage">
<section class="product-homepage-banner" :class="bannerStatusClass">
<div class="product-homepage-banner__identity">
<div class="product-homepage-banner__title-group">
<div class="product-homepage-banner__title-main min-w-0">
<div class="product-homepage-banner__title-row">
<h1 class="product-homepage-banner__title">{{ homepageBanner.identity.name }}</h1>
<span class="product-homepage-banner__status-word" :class="bannerStatusWordClass">
{{ homepageBanner.identity.statusLabel }}
</span>
</div>
<div class="product-homepage-banner__subtitle">
<span class="product-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
<p v-if="homepageBanner.identity.description" class="product-homepage-banner__description">
{{ homepageBanner.identity.description }}
</p>
</div>
</div>
</div>
<div class="product-homepage-banner__facts">
<div
v-for="item in bannerFacts"
:key="item.label"
class="product-homepage-banner__fact"
:class="{ 'product-homepage-banner__fact--full': item.fullWidth }"
>
<span class="product-homepage-banner__fact-label">{{ item.label }}</span>
<strong class="product-homepage-banner__fact-value">{{ item.value }}</strong>
</div>
</div>
</div>
<div class="product-homepage-banner__metrics">
<article v-for="item in homepageBanner.metrics" :key="item.label" class="product-homepage-banner__metric">
<span class="product-homepage-banner__metric-label">{{ item.label }}</span>
<strong class="product-homepage-banner__metric-value">{{ item.value }}</strong>
</article>
</div>
</section>
<section class="product-homepage-main">
<ProductActivityTimelinePanel
:product-id="currentObjectId || ''"
@latest-time-change="handleLatestActivityTimeChange"
/>
<div class="product-homepage-main__aside">
<ElCard class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">需求池管理概览</h3>
<p class="product-homepage-panel__desc">先看需求池现在的总体规模状态结构和待处理压力</p>
</div>
</template>
<div class="product-homepage-requirement-summary">
<div class="product-homepage-requirement-summary__metrics">
<article
v-for="item in requirementPoolSummary.metrics"
:key="item.label"
class="product-homepage-requirement-summary__metric"
>
<span class="product-homepage-requirement-summary__metric-label">{{ item.label }}</span>
<strong class="product-homepage-requirement-summary__metric-value">{{ item.value }}</strong>
</article>
</div>
<div class="product-homepage-requirement-summary__distribution">
<div
v-for="item in requirementPoolSummary.distribution"
:key="item.label"
class="product-homepage-requirement-summary__distribution-item"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</ElCard>
<ElCard class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">需求池最近变化</h3>
<p class="product-homepage-panel__desc">承接需求新增状态流转和关闭情况和产品动态时间线分开表达</p>
</div>
</template>
<div v-if="requirementPoolRecentChanges.length" class="product-homepage-requirement-changes">
<article
v-for="item in requirementPoolRecentChanges"
:key="item.id"
class="product-homepage-requirement-changes__item"
>
<div class="product-homepage-requirement-changes__meta">
<ElTag type="info" effect="plain" size="small">{{ item.actionLabel }}</ElTag>
<span class="product-homepage-requirement-changes__time">{{ item.time }}</span>
</div>
<strong class="product-homepage-requirement-changes__title">{{ item.title }}</strong>
<p class="product-homepage-requirement-changes__status">当前状态{{ item.statusLabel }}</p>
</article>
</div>
<ElEmpty v-else description="当前暂无需求池最近变化" :image-size="72" />
</ElCard>
</div>
</section>
<section class="product-homepage-extension">
<ElCard v-for="module in extensionModules" :key="module.key" class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">{{ module.title }}</h3>
<p class="product-homepage-panel__desc">{{ module.description }}</p>
</div>
</template>
<div class="product-homepage-extension__list">
<div v-for="item in module.items" :key="item" class="product-homepage-extension__item">
<span class="product-homepage-extension__dot" />
<span>{{ item }}</span>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.product-homepage {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-homepage-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 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%));
}
.product-homepage-banner--default {
border-color: rgb(226 232 240 / 92%);
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%));
}
.product-homepage-banner--active {
border-color: rgb(167 243 208 / 88%);
background:
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--paused {
border-color: rgb(253 230 138 / 90%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--abandoned {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner__identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.product-homepage-banner__title-group {
display: flex;
align-items: flex-start;
gap: 12px;
}
.product-homepage-banner__title-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
}
.product-homepage-banner__title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 14px;
}
.product-homepage-banner__code {
margin: 0;
color: rgb(14 116 144 / 92%);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.product-homepage-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 34px;
line-height: 1.15;
letter-spacing: -0.03em;
}
.product-homepage-banner__subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.product-homepage-banner__description {
margin: 0;
min-width: 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.8;
}
.product-homepage-banner__status-word {
flex-shrink: 0;
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
user-select: none;
}
.product-homepage-banner__status-word--default {
color: rgb(148 163 184 / 48%);
}
.product-homepage-banner__status-word--active {
color: transparent;
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
}
.product-homepage-banner__status-word--paused {
color: transparent;
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
}
.product-homepage-banner__status-word--archived {
color: transparent;
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
}
.product-homepage-banner__status-word--abandoned {
color: transparent;
background: linear-gradient(180deg, rgb(225 29 72 / 94%), rgb(251 113 133 / 68%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(225 29 72 / 16%);
}
.product-homepage-banner__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-banner__fact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 58px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.product-homepage-banner__fact--full {
grid-column: 1 / -1;
align-items: flex-start;
}
.product-homepage-banner__fact-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
white-space: nowrap;
}
.product-homepage-banner__fact-value {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
text-align: right;
}
.product-homepage-banner__fact--full .product-homepage-banner__fact-value {
max-width: 72%;
text-align: left;
}
.product-homepage-banner__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-banner__metric {
display: flex;
min-height: 112px;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 18px;
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%));
}
.product-homepage-banner__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.product-homepage-banner__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.02em;
word-break: break-word;
}
.product-homepage-main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.product-homepage-main__aside {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.product-homepage-panel {
overflow: hidden;
}
.product-homepage-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.product-homepage-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.product-homepage-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-timeline__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.product-homepage-timeline__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.product-homepage-timeline__dot {
width: 12px;
height: 12px;
border-radius: 999px;
margin-top: 6px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.product-homepage-timeline__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.product-homepage-timeline__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.product-homepage-timeline__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.product-homepage-timeline__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.product-homepage-timeline__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.product-homepage-timeline__line {
flex: 1;
width: 2px;
min-height: 30px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.product-homepage-timeline__item:last-child .product-homepage-timeline__line {
opacity: 0;
}
.product-homepage-timeline__content {
padding: 12px 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.product-homepage-timeline__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-homepage-timeline__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-homepage-timeline__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.65;
}
.product-homepage-timeline__headline {
margin-right: 6px;
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.product-homepage-requirement-summary {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-homepage-requirement-summary__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-requirement-summary__metric {
display: flex;
min-height: 100px;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.product-homepage-requirement-summary__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.product-homepage-requirement-summary__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 24px;
line-height: 1.1;
}
.product-homepage-requirement-summary__distribution {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-requirement-summary__distribution-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 14px;
background-color: rgb(255 255 255 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
}
.product-homepage-requirement-summary__distribution-item strong {
color: rgb(15 23 42 / 98%);
font-size: 18px;
}
.product-homepage-requirement-changes {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-homepage-requirement-changes__item {
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 18px;
background-color: rgb(255 255 255 / 98%);
}
.product-homepage-requirement-changes__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.product-homepage-requirement-changes__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-homepage-requirement-changes__title {
display: block;
margin-top: 10px;
color: rgb(15 23 42 / 98%);
font-size: 15px;
line-height: 1.65;
}
.product-homepage-requirement-changes__status {
margin: 8px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.7;
}
.product-homepage-extension {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.product-homepage-extension__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-extension__item {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
line-height: 1.7;
}
.product-homepage-extension__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background-color: rgb(14 116 144 / 88%);
flex-shrink: 0;
}
@media (width <= 1280px) {
.product-homepage-banner,
.product-homepage-main,
.product-homepage-extension {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.product-homepage-banner {
padding: 18px;
}
.product-homepage-banner__title-row {
flex-wrap: wrap;
}
.product-homepage-banner__title {
font-size: 28px;
}
.product-homepage-banner__status-word {
font-size: 22px;
}
.product-homepage-banner__facts,
.product-homepage-banner__metrics,
.product-homepage-requirement-summary__metrics {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { fetchGetProductActivityTimelinePage } from '@/service/api'; import { fetchGetProductActivityTimelinePage } from '@/service/api';
import { import {
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE, DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
@@ -13,6 +13,7 @@ defineOptions({ name: 'ProductActivityTimelinePanel' });
interface Props { interface Props {
productId: string; productId: string;
maxItems?: number;
} }
interface Emits { interface Emits {
@@ -27,6 +28,11 @@ const loading = ref(false);
const loadError = ref(false); const loadError = ref(false);
const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]); const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]);
const displayItems = computed(() => {
if (!props.maxItems || props.maxItems <= 0) return items.value;
return items.value.slice(0, props.maxItems);
});
async function loadRecentActivities() { async function loadRecentActivities() {
if (!props.productId) { if (!props.productId) {
items.value = []; items.value = [];
@@ -79,11 +85,17 @@ watch(
</script> </script>
<template> <template>
<ElCard class="product-activity-panel card-wrapper"> <ElCard class="product-activity-panel card-wrapper" shadow="never">
<template #header> <template #header>
<div class="product-activity-panel__header"> <div class="product-activity-panel__header">
<div> <div class="product-activity-panel__header-main">
<span class="product-activity-panel__header-icon">
<SvgIcon icon="mdi:timeline-clock-outline" />
</span>
<div class="product-activity-panel__header-text">
<h3 class="product-activity-panel__title">产品动态时间线</h3> <h3 class="product-activity-panel__title">产品动态时间线</h3>
<p class="product-activity-panel__desc">对象状态与团队的最近活动</p>
</div>
</div> </div>
<ElButton text type="primary" :disabled="!productId" @click="openDialog">更多</ElButton> <ElButton text type="primary" :disabled="!productId" @click="openDialog">更多</ElButton>
@@ -96,8 +108,8 @@ watch(
<ElButton type="primary" plain @click="loadRecentActivities">重新加载</ElButton> <ElButton type="primary" plain @click="loadRecentActivities">重新加载</ElButton>
</div> </div>
<div v-else-if="items.length" class="product-activity-panel__timeline"> <div v-else-if="displayItems.length" class="product-activity-panel__timeline">
<article v-for="item in items" :key="item.id" class="product-activity-panel__item"> <article v-for="item in displayItems" :key="item.id" class="product-activity-panel__item">
<div class="product-activity-panel__rail"> <div class="product-activity-panel__rail">
<span class="product-activity-panel__dot" :class="`product-activity-panel__dot--${item.tone}`" /> <span class="product-activity-panel__dot" :class="`product-activity-panel__dot--${item.tone}`" />
<span class="product-activity-panel__line" /> <span class="product-activity-panel__line" />
@@ -139,11 +151,36 @@ watch(
.product-activity-panel__header { .product-activity-panel__header {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.product-activity-panel__header-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.product-activity-panel__header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 12px;
font-size: 19px;
color: #fff;
flex-shrink: 0;
background: linear-gradient(135deg, #38bdf8, #0284c7);
box-shadow: 0 6px 14px -8px rgb(2 132 199 / 55%);
}
.product-activity-panel__header-text {
min-width: 0;
}
.product-activity-panel__title { .product-activity-panel__title {
margin: 0; margin: 0;
color: rgb(15 23 42 / 98%); color: rgb(15 23 42 / 98%);
@@ -152,19 +189,19 @@ watch(
} }
.product-activity-panel__desc { .product-activity-panel__desc {
margin: 4px 0 0; margin: 3px 0 0;
color: rgb(100 116 139 / 92%); color: rgb(100 116 139 / 92%);
font-size: 13px; font-size: 12px;
line-height: 1.7; line-height: 1.6;
} }
.product-activity-panel__body { .product-activity-panel__body {
min-height: 520px; min-height: auto;
} }
.product-activity-panel__state { .product-activity-panel__state {
display: flex; display: flex;
min-height: 420px; min-height: 200px;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -273,10 +310,6 @@ watch(
} }
@media (width <= 768px) { @media (width <= 768px) {
.product-activity-panel__body {
min-height: auto;
}
.product-activity-panel__header { .product-activity-panel__header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View File

@@ -0,0 +1,134 @@
import { type Ref, computed, markRaw } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { canReportTaskWorklog } from '../shared';
import { useTaskPermissions } from './use-task-permissions';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
export interface TaskAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'danger' | 'warning';
onClick: () => void;
}
export interface TaskActionEmits {
edit: (row: Api.Project.ProjectTask) => void;
report: (row: Api.Project.ProjectTask) => void;
remove: (row: Api.Project.ProjectTask) => void;
statusAction: (
row: Api.Project.ProjectTask,
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>
) => void;
}
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
pause: markRaw(IconMdiPause),
complete: markRaw(IconMdiCheckCircleOutline),
resume: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline)
};
// 状态推进按钮 type 映射(对齐执行 execution-list-panel.vue 同源语义):
// cancel 破坏性=红pause 中断=橙complete 完结=绿resume 主动作=蓝
const STATUS_ACTION_TYPE_MAP: Record<string, TaskAction['type']> = {
cancel: 'danger',
pause: 'warning',
complete: 'success',
resume: 'primary'
};
// 同一状态下多个推进按钮的展示顺序,与执行侧保持一致:暂停 → 取消 → 完成 → 恢复
const STATUS_ACTION_ORDER: Record<string, number> = {
pause: 1,
cancel: 2,
complete: 3,
resume: 4
};
/**
* 任务行操作按钮的集中装配。
*
* 表格操作列与看板卡片操作区共用同一份语义:填报 / 编辑 / 删除 / 状态推进按钮,
* 含 auto_start 过滤、complete 进度 100% 兜底、按钮排序与 icon/type 映射。
*
* dataRef 用于填报按钮的"叶子"判定canReportTaskWorklog 需要全量行集合)。
*/
export function useTaskActions(dataRef: Ref<Api.Project.ProjectTask[]>, emits: TaskActionEmits) {
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions();
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = [];
// 填报:权限码门槛 AND 业务规则(叶子/身份/状态)双重判定
if (hasReportWorklogPermission() && canReportTaskWorklog(row, dataRef.value, currentUserId.value)) {
actions.push({
key: 'report',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: () => emits.report(row)
});
}
if (canEditTask(row)) {
actions.push({
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: () => emits.edit(row)
});
}
if (canDeleteTask(row)) {
actions.push({
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: () => emits.remove(row)
});
}
if (!row.availableActions.length) {
return actions;
}
// 排序:暂停 → 取消 → 完成 → 恢复(对齐执行)
const sortedActions = [...row.availableActions].sort(
(a, b) => (STATUS_ACTION_ORDER[a.actionCode] ?? 99) - (STATUS_ACTION_ORDER[b.actionCode] ?? 99)
);
sortedActions.forEach(action => {
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染
if (action.actionCode === 'auto_start') {
return;
}
// 完成任务至少要求任务进度达到 100%;父级提交入口仍保留同样兜底校验
if (action.actionCode === 'complete' && row.progressRate < 100) {
return;
}
actions.push({
key: `status-${action.actionCode}`,
tooltip: action.actionName,
icon: markRaw(STATUS_ACTION_ICON_MAP[action.actionCode] ?? IconMdiSync),
type: STATUS_ACTION_TYPE_MAP[action.actionCode] ?? 'primary',
onClick: () => emits.statusAction(row, action)
});
});
return actions;
}
return { createActions };
}

View File

@@ -0,0 +1,125 @@
import { type Ref, ref, watch } from 'vue';
import { fetchGetProjectTaskBoardPage } from '@/service/api/project';
export interface BoardColumnState {
statusCode: string;
title: string;
sort: number;
terminal: boolean;
/** 服务端权威总数(每次接口回来都用最新 total 覆盖,与已加载多少条无关) */
total: number;
tasks: Api.Project.ProjectTask[];
/** 已加载到第几页 */
pageNo: number;
loading: boolean;
hasMore: boolean;
}
const DEFAULT_PAGE_SIZE = 20;
export type BoardBaseParams = Pick<
Api.Project.ProjectTaskSearchParams,
'keyword' | 'parentTaskId' | 'ownerId' | 'updateTime'
>;
export interface UseTaskBoardColumnsOptions {
projectId: Ref<string>;
executionId: Ref<string>;
/**
* 刷新触发器workspace 的 statusBoard ref。
*
* 这里只把它当"事件源"用:搜索/重置/创建/编辑/删除/状态推进后 workspace 都会重新拉它 → ref 引用变 →
* 触发本 composable 重新拉看板首屏。**composable 不读它的内容**,列结构以 board-page 响应为准。
*/
refreshSignal: Ref<unknown>;
baseParams: () => BoardBaseParams;
pageSize?: number;
}
/**
* 看板按状态分列、每列独立分页(无限下拉)。
*
* 节奏:
* - 进入看板 / 任何刷新事件1 次 board-page不带 statusCode→ 拿到所有列骨架 + 各列首页 + 各列总数。
* - 用户滚某列到底1 次 board-page`statusCode=[X]`, `pageNo=N+1`)→ 取 `items[0].list` 追加到本列。
*
* 不消费 searchParams.statusCode —— 每列总是查自己的 statusCode顶部搜索栏的状态过滤在看板模式下天然失效。
*/
export function useTaskBoardColumns(options: UseTaskBoardColumnsOptions) {
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
const columns = ref<BoardColumnState[]>([]);
async function refresh() {
if (!options.projectId.value || !options.executionId.value) {
columns.value = [];
return;
}
const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, {
...options.baseParams(),
pageNo: 1,
pageSize
});
if (result.error || !result.data) {
columns.value = [];
return;
}
// 后端按 sort 升序返回,前端不再排
columns.value = result.data.items.map(item => ({
statusCode: item.statusCode,
title: item.statusName,
sort: item.sort,
terminal: Boolean(item.terminal),
total: item.total,
tasks: item.list,
pageNo: 1,
loading: false,
hasMore: item.list.length < item.total
}));
}
async function loadMore(statusCode: string) {
if (!options.projectId.value || !options.executionId.value) return;
const col = columns.value.find(item => item.statusCode === statusCode);
if (!col || col.loading || !col.hasMore) return;
col.loading = true;
const nextPage = col.pageNo + 1;
const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, {
...options.baseParams(),
statusCode: [statusCode],
pageNo: nextPage,
pageSize
});
if (result.error || !result.data) {
col.loading = false;
return;
}
const colData = result.data.items[0];
if (!colData) {
// 后端没回该列(理论上不该发生)—— 兜底标记加载完毕,避免死循环
col.loading = false;
col.hasMore = false;
return;
}
col.tasks = [...col.tasks, ...colData.list];
col.pageNo = nextPage;
col.total = colData.total;
col.hasMore = col.tasks.length < colData.total;
col.loading = false;
}
watch(
() => options.refreshSignal.value,
() => refresh(),
{ immediate: true }
);
return { columns, loadMore, refresh };
}

View File

@@ -9,6 +9,7 @@ import { useObjectContextStore } from '@/store/modules/object-context';
* - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决) * - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决)
* - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务 * - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务
* - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管) * - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管)
* - 执行负责人能维护一级任务协办人(一级任务 `executionOwnerId` 通道;子任务不开此通道)
* - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态 * - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态
* *
* 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码** * 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**
@@ -107,7 +108,11 @@ export function useTaskPermissions() {
} }
function canManageTaskAssignee(task: Api.Project.ProjectTask): boolean { function canManageTaskAssignee(task: Api.Project.ProjectTask): boolean {
return isMutable(task) && (hasPermission('project:task:assignee') || currentUserId.value === task.ownerId); if (!isMutable(task)) return false;
if (hasPermission('project:task:assignee')) return true;
if (currentUserId.value === task.ownerId) return true;
// 一级任务执行负责人也可维护协办人子任务父任务负责人不在此放行§4.2
return isTopLevelTask(task) && currentUserId.value === task.executionOwnerId;
} }
function canReportTaskWorklog(): boolean { function canReportTaskWorklog(): boolean {

View File

@@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
import { Edit, Flag, User } from '@element-plus/icons-vue'; import { Calendar, Flag, User } from '@element-plus/icons-vue';
import { formatDate, getProgressText, getTaskStatusName } from '../shared'; import { formatDateRange, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions'; import { useTaskActions } from '../composables/use-task-actions';
import { type BoardBaseParams, useTaskBoardColumns } from '../composables/use-task-board-columns';
defineOptions({ name: 'ProjectExecutionTaskBoardView' }); defineOptions({ name: 'ProjectExecutionTaskBoardView' });
interface Props { interface Props {
data: Api.Project.ProjectTask[]; projectId: string;
loading: boolean; executionId: string;
statusBoard: Api.Project.StatusBoard | null; statusBoard: Api.Project.StatusBoard | null;
baseParams: BoardBaseParams;
} }
const { canEditTask } = useTaskPermissions();
interface Emits { interface Emits {
(e: 'detail', row: Api.Project.ProjectTask): void; (e: 'detail', row: Api.Project.ProjectTask): void;
(e: 'edit', row: Api.Project.ProjectTask): void; (e: 'edit', row: Api.Project.ProjectTask): void;
(e: 'report', row: Api.Project.ProjectTask): void;
(e: 'delete', row: Api.Project.ProjectTask): void;
( (
e: 'status-action', e: 'status-action',
row: Api.Project.ProjectTask, row: Api.Project.ProjectTask,
@@ -27,49 +29,85 @@ interface Emits {
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const groupedTasks = computed(() => { const { columns, loadMore } = useTaskBoardColumns({
const map = new Map<string, Api.Project.ProjectTask[]>(); projectId: toRef(props, 'projectId'),
const items = props.statusBoard?.items ?? []; executionId: toRef(props, 'executionId'),
// statusBoard 在此仅作"刷新事件源"workspace 任何变更后都会重新拉它ref 引用变即触发本看板重拉首屏
items.forEach(item => { refreshSignal: toRef(props, 'statusBoard'),
map.set(item.statusCode, []); baseParams: () => props.baseParams
}); });
props.data.forEach(task => { // 看板卡片操作按钮(与表格操作列同语义)。
const list = map.get(task.statusCode); // 兼容 useTaskActions 的"叶子判定"需求:拍平当前已加载的全部任务做集合。
const allLoadedTasks = computed(() => columns.value.flatMap(item => item.tasks));
if (list) { const { createActions } = useTaskActions(allLoadedTasks, {
list.push(task); edit: row => emit('edit', row),
report: row => emit('report', row),
remove: row => emit('delete', row),
statusAction: (row, action) => emit('status-action', row, action)
});
// 每个列对应一个底部 sentinelIntersectionObserver 观测到入视即触发 loadMore。
// IO 的 root 用 null视口即可列的 overflow 裁剪不影响 IO 的 boundingClientRect 判定,
// sentinel 实际是否能"被滚到"由 column 滚动条决定。
const sentinelRegistry = new Map<HTMLElement, string>();
let observer: IntersectionObserver | null = null;
function handleSentinelRef(el: Element | null, statusCode: string) {
if (el instanceof HTMLElement) {
sentinelRegistry.set(el, statusCode);
observer?.observe(el);
return;
}
// el === nullsentinel 卸载hasMore 变 false—— 按 statusCode 反查清理,避免 observer 持有失效 DOM
for (const [existing, code] of sentinelRegistry) {
if (code === statusCode) {
observer?.unobserve(existing);
sentinelRegistry.delete(existing);
break;
}
}
}
onMounted(() => {
observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const statusCode = sentinelRegistry.get(entry.target as HTMLElement);
if (statusCode) {
loadMore(statusCode);
} }
}); });
},
return items.map(item => ({ { rootMargin: '200px 0px' }
statusCode: item.statusCode, );
title: item.statusName, sentinelRegistry.forEach((_, el) => observer!.observe(el));
count: item.count,
tasks: map.get(item.statusCode) || []
}));
}); });
function getFirstAction(row: Api.Project.ProjectTask) { onBeforeUnmount(() => {
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染按钮 observer?.disconnect();
return row.availableActions.find(item => item.actionCode !== 'auto_start') || null; observer = null;
} sentinelRegistry.clear();
});
</script> </script>
<template> <template>
<ElCard class="task-board-card" body-class="task-board-card__body"> <ElCard class="task-board-card" body-class="task-board-card__body">
<ElSkeleton v-if="loading" :rows="6" animated /> <div class="task-board">
<div v-else class="task-board"> <section v-for="column in columns" :key="column.statusCode" class="task-board-column">
<section v-for="column in groupedTasks" :key="column.statusCode" class="task-board-column">
<header class="task-board-column__header"> <header class="task-board-column__header">
<strong>{{ column.title }}</strong> <strong>{{ column.title }}</strong>
<ElTag effect="plain" size="small">{{ column.count }}</ElTag> <ElTag effect="plain" size="small" round>{{ column.total }}</ElTag>
</header> </header>
<ElScrollbar class="task-board-column__scrollbar"> <ElScrollbar class="task-board-column__scrollbar">
<ElEmpty v-if="column.tasks.length === 0" :description="`暂无${column.title}任务`" /> <ElEmpty
<template v-else> v-if="column.tasks.length === 0 && !column.loading"
:description="`暂无${column.title}任务`"
:image-size="64"
/>
<article <article
v-for="task in column.tasks" v-for="task in column.tasks"
:key="task.id" :key="task.id"
@@ -78,7 +116,9 @@ function getFirstAction(row: Api.Project.ProjectTask) {
> >
<div class="task-board-card-item__top"> <div class="task-board-card-item__top">
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong> <strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
<ElTag effect="plain" size="small">{{ getTaskStatusName(task) }}</ElTag> <ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small">
{{ getTaskStatusName(task) }}
</ElTag>
</div> </div>
<div class="task-board-card-item__meta"> <div class="task-board-card-item__meta">
@@ -88,7 +128,11 @@ function getFirstAction(row: Api.Project.ProjectTask) {
</span> </span>
<span> <span>
<ElIcon><Flag /></ElIcon> <ElIcon><Flag /></ElIcon>
计划结束 {{ formatDate(task.plannedEndDate) }} 计划 {{ formatDateRange(task.plannedStartDate, task.plannedEndDate) }}
</span>
<span>
<ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(task.actualStartDate, task.actualEndDate) }}
</span> </span>
</div> </div>
@@ -97,31 +141,24 @@ function getFirstAction(row: Api.Project.ProjectTask) {
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" /> <ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
</div> </div>
<div class="task-board-card-item__actions" @click.stop> <div v-if="createActions(task).length" class="task-board-card-item__actions" @click.stop>
<ElButton v-if="canEditTask(task)" size="small" plain :icon="Edit" @click="emit('edit', task)"> <ElTooltip v-for="action in createActions(task)" :key="action.key" :content="action.tooltip">
编辑 <ElButton link :type="action.type" class="task-action-btn" @click="action.onClick()">
</ElButton> <component :is="action.icon" class="text-15px" />
<ElButton
v-if="getFirstAction(task)"
size="small"
type="primary"
plain
@click="emit('status-action', task, getFirstAction(task)!)"
>
{{ getFirstAction(task)!.actionName }}
</ElButton>
<ElButton
v-else-if="task.availableActions.length === 0 && task.statusCode !== 'cancelled'"
size="small"
type="primary"
plain
@click="emit('status-action', task, null)"
>
状态
</ElButton> </ElButton>
</ElTooltip>
</div> </div>
</article> </article>
</template>
<div
v-if="column.hasMore"
:ref="el => handleSentinelRef(el as Element | null, column.statusCode)"
class="task-board-column__sentinel"
>
<span v-if="column.loading">加载中</span>
<span v-else class="task-board-column__sentinel-idle">下拉加载</span>
</div>
<div v-else-if="column.tasks.length > 0" class="task-board-column__footer">已加载全部</div>
</ElScrollbar> </ElScrollbar>
</section> </section>
</div> </div>
@@ -235,12 +272,36 @@ function getFirstAction(row: Api.Project.ProjectTask) {
.task-board-card-item__actions { .task-board-card-item__actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 6px;
margin-top: 12px; margin-top: 12px;
padding-top: 8px;
border-top: 1px dashed rgb(226 232 240 / 92%);
} }
.task-board-card-item__actions :deep(.el-button + .el-button) { .task-board-card-item__actions :deep(.el-button + .el-button) {
margin-left: 0; margin-left: 0;
} }
:deep(.task-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
.task-board-column__sentinel,
.task-board-column__footer {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
color: rgb(148 163 184 / 92%);
font-size: 12px;
}
.task-board-column__sentinel-idle {
opacity: 0.6;
}
</style> </style>

View File

@@ -1,23 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, markRaw } from 'vue'; import { computed, toRef } from 'vue';
import type { PaginationProps } from 'element-plus'; import type { PaginationProps } from 'element-plus';
import { useAuthStore } from '@/store/modules/auth'; import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { import { useTaskActions } from '../composables/use-task-actions';
canReportTaskWorklog,
formatDateRange,
formatDateTime,
getTaskStatusName,
getTaskStatusTagType
} from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'ProjectExecutionTaskTableView' }); defineOptions({ name: 'ProjectExecutionTaskTableView' });
@@ -42,10 +27,12 @@ interface Emits {
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const authStore = useAuthStore(); const { createActions } = useTaskActions(toRef(props, 'data'), {
const currentUserId = computed(() => authStore.userInfo.userId || ''); edit: row => emit('edit', row),
report: row => emit('report', row),
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions(); remove: row => emit('delete', row),
statusAction: (row, action) => emit('status-action', row, action)
});
const paginationVisible = computed(() => Boolean(props.pagination.total)); const paginationVisible = computed(() => Boolean(props.pagination.total));
@@ -66,80 +53,6 @@ function getParentTaskLabel(parentTaskId: string | null) {
return taskTitleMap.value.get(parentTaskId) || '--'; return taskTitleMap.value.get(parentTaskId) || '--';
} }
interface TaskAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'danger';
onClick: () => void;
}
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
pause: markRaw(IconMdiPause),
complete: markRaw(IconMdiCheckCircleOutline),
resume: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline)
};
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = [];
// 填报:权限码门槛 AND 业务规则(叶子/身份/状态)双重判定
if (hasReportWorklogPermission() && canReportTaskWorklog(row, props.data, currentUserId.value)) {
actions.push({
key: 'report',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: () => emit('report', row)
});
}
if (canEditTask(row)) {
actions.push({
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: () => emit('edit', row)
});
}
if (canDeleteTask(row)) {
actions.push({
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: () => emit('delete', row)
});
}
if (!row.availableActions.length) {
return actions;
}
row.availableActions.forEach(action => {
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染
if (action.actionCode === 'auto_start') {
return;
}
// 完成任务至少要求任务进度达到 100%;父级提交入口仍保留同样兜底校验。
if (action.actionCode === 'complete' && row.progressRate < 100) {
return;
}
actions.push({
key: `status-${action.actionCode}`,
tooltip: action.actionName,
icon: markRaw(STATUS_ACTION_ICON_MAP[action.actionCode] ?? IconMdiSync),
type: action.actionCode === 'cancel' ? 'danger' : 'success',
onClick: () => emit('status-action', row, action)
});
});
return actions;
}
function handlePageChange(page: number) { function handlePageChange(page: number) {
props.pagination['current-change']?.(page); props.pagination['current-change']?.(page);
} }

View File

@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project'; import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared'; import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import type { WorklogChangedPayload } from '../shared'; import type { WorklogChangedPayload } from '../shared';
import TaskWorklogPanel from './task-worklog-panel.vue'; import TaskWorklogPanel from './task-worklog-panel.vue';
@@ -24,8 +25,26 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const authStore = useAuthStore(); const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const currentUserId = computed(() => authStore.userInfo.userId || ''); const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value)); const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value));
const isActiveAssignee = computed(() =>
Boolean(props.task?.assignees?.some(item => item.userId === currentUserId.value))
);
// 工时面板顶部「填报」按钮的可见度与任务行操作列的「填报」按钮同源§4.8.4 矩阵 + 业务事实修正):
// - 权限码 project:task:worklog
// - 身份:任务负责人 OR 活跃协办人
// - 状态pending首次填触发 auto_startOR active OR completedcompleted 后填报不回写进度,由 form-dialog 内进度只读兜底)
// 不做叶子判定——详情入口已锁定单条任务,无父子歧义
const canSubmitWorklog = computed(() => {
if (!props.task || !currentUserId.value) return false;
if (!objectContextStore.buttonCodes.includes('project:task:worklog')) return false;
if (!isOwner.value && !isActiveAssignee.value) return false;
return (
props.task.statusCode === 'pending' || props.task.statusCode === 'active' || props.task.statusCode === 'completed'
);
});
const records = ref<Api.Project.TaskWorklog[]>([]); const records = ref<Api.Project.TaskWorklog[]>([]);
const recordsLoading = ref(false); const recordsLoading = ref(false);
@@ -212,10 +231,11 @@ watch(
:execution-id="task.executionId" :execution-id="task.executionId"
:task-id="task.id" :task-id="task.id"
:task-owner-id="task.ownerId" :task-owner-id="task.ownerId"
:task-status-code="task.statusCode"
:owner-nickname="task.ownerNickname" :owner-nickname="task.ownerNickname"
:assignees="task.assignees" :assignees="task.assignees"
:task-progress-rate="task.progressRate" :task-progress-rate="task.progressRate"
:can-submit="true" :can-submit="canSubmitWorklog"
:external-list="records" :external-list="records"
:show-assignee-column="isOwner" :show-assignee-column="isOwner"
@changed="handleWorklogChanged" @changed="handleWorklogChanged"

View File

@@ -19,6 +19,8 @@ interface Props {
executionId: string; executionId: string;
taskId: string; taskId: string;
taskOwnerId: string | null; taskOwnerId: string | null;
/** 任务状态码completed 时进度字段只读(后端不回写 task.progressRate避免误改 */
taskStatusCode: string;
/** 创建模式下的进度兜底默认值owner 路径会传 task.progressRate */ /** 创建模式下的进度兜底默认值owner 路径会传 task.progressRate */
defaultOwnerProgressRate?: number; defaultOwnerProgressRate?: number;
/** 提交中HTTP 进行中),由父组件控制 */ /** 提交中HTTP 进行中),由父组件控制 */
@@ -43,6 +45,8 @@ const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || ''); const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.taskOwnerId && props.taskOwnerId === currentUserId.value)); const isOwner = computed(() => Boolean(props.taskOwnerId && props.taskOwnerId === currentUserId.value));
const isView = computed(() => props.mode === 'view'); const isView = computed(() => props.mode === 'view');
// 任务 completed 后填工时不回写进度§4.8.4 备注),此时进度字段只读,避免误导用户以为能改任务进度
const isProgressReadonly = computed(() => isView.value || props.taskStatusCode === 'completed');
const { formRef, validate } = useForm(); const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules(); const { createRequiredRule } = useFormRules();
@@ -399,7 +403,7 @@ defineExpose({
:max="100" :max="100"
:step="1" :step="1"
:precision="2" :precision="2"
:disabled="isView" :disabled="isProgressReadonly"
controls-position="right" controls-position="right"
class="w-full" class="w-full"
/> />

View File

@@ -25,6 +25,8 @@ interface Props {
executionId: string; executionId: string;
taskId: string; taskId: string;
taskOwnerId: string | null; taskOwnerId: string | null;
/** 任务状态码;用于编辑/删除按钮的状态前置active / completed 才允许,与 §4.8.4 矩阵对齐) */
taskStatusCode: string;
/** 当前用户是否被允许填报owner 或活跃协办人) */ /** 当前用户是否被允许填报owner 或活跃协办人) */
canSubmit: boolean; canSubmit: boolean;
/** 用于 form-dialog 在 owner 路径下显示默认进度task.progressRate */ /** 用于 form-dialog 在 owner 路径下显示默认进度task.progressRate */
@@ -182,13 +184,18 @@ function getRowIndex(index: number) {
const canCreate = computed(() => Boolean(props.canSubmit && props.taskId)); const canCreate = computed(() => Boolean(props.canSubmit && props.taskId));
// 编辑 / 删除自己工时按矩阵 §4.8.4:仅本人 + 任务在 active / completed
// completed 态进入 worklog 编辑弹层时,进度字段会自动只读,避免触发任务进度回写
const isWorklogMutableStatus = computed(
() => props.taskStatusCode === 'active' || props.taskStatusCode === 'completed'
);
function canEditRow(row: Api.Project.TaskWorklog) { function canEditRow(row: Api.Project.TaskWorklog) {
return Boolean(currentUserId.value && row.userId === currentUserId.value); return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
} }
// 编辑 / 删除均仅本人;非本人按钮渲染为 disabled让责任人也能感知"这条不归我管"
function canDeleteRow(row: Api.Project.TaskWorklog) { function canDeleteRow(row: Api.Project.TaskWorklog) {
return Boolean(currentUserId.value && row.userId === currentUserId.value); return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
} }
function formatHours(hours: number | null | undefined) { function formatHours(hours: number | null | undefined) {
@@ -591,6 +598,7 @@ watch(
:execution-id="executionId" :execution-id="executionId"
:task-id="taskId" :task-id="taskId"
:task-owner-id="taskOwnerId" :task-owner-id="taskOwnerId"
:task-status-code="taskStatusCode"
:default-owner-progress-rate="taskProgressRate" :default-owner-progress-rate="taskProgressRate"
:confirm-loading="submitting" :confirm-loading="submitting"
@submit="handleSubmit" @submit="handleSubmit"

View File

@@ -192,13 +192,23 @@ function resetSearchParams() {
searchParams.updateTime = undefined; searchParams.updateTime = undefined;
} }
/**
* 看板模式下表格不可见,且 `/tasks/page` 与 `/tasks/board-page` 是两份独立的分页流,
* 同时跑就是一次"查询"打两次分页请求。这里在看板模式下短路掉表格分页,
* 等用户切回表格视图时再由 viewMode 监听补一次。
*/
async function refreshTableData(resetToFirstPage = false) {
if (viewMode.value !== 'table') return;
await (resetToFirstPage ? getDataByPage(1) : getData());
}
async function handleSearch() { async function handleSearch() {
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]); await Promise.all([refreshTableData(true), loadTaskStatusBoard()]);
} }
async function handleReset() { async function handleReset() {
resetSearchParams(); resetSearchParams();
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]); await Promise.all([refreshTableData(true), loadTaskStatusBoard()]);
} }
function handleCreate() { function handleCreate() {
@@ -294,7 +304,7 @@ async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
// 业务保存成功才 commit删除用户在弹层里标记删除的附件 // 业务保存成功才 commit删除用户在弹层里标记删除的附件
await taskOperateDialogRef.value?.commit(); await taskOperateDialogRef.value?.commit();
operateVisible.value = false; operateVisible.value = false;
await getData(); await refreshTableData();
} }
async function handleStatusSubmit(reason: string | null) { async function handleStatusSubmit(reason: string | null) {
@@ -321,7 +331,7 @@ async function handleStatusSubmit(reason: string | null) {
const completedTask = currentTask.value; const completedTask = currentTask.value;
pendingCascade.value = false; pendingCascade.value = false;
await Promise.all([getData(), loadTaskStatusBoard()]); await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
emit('executionChanged'); emit('executionChanged');
if (wasCascade && completedTask) { if (wasCascade && completedTask) {
@@ -345,7 +355,7 @@ async function handleReport(row: Api.Project.ProjectTask) {
} }
async function handleWorklogChanged(payload: WorklogChangedPayload) { async function handleWorklogChanged(payload: WorklogChangedPayload) {
await Promise.all([getData(), loadTaskStatusBoard()]); await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
// 工时变化可能触发后端联动改任务状态、实际开始、进度等; // 工时变化可能触发后端联动改任务状态、实际开始、进度等;
// 同步刷新当前打开弹层的 task 快照,避免用户必须关弹层重开才能看到新值 // 同步刷新当前打开弹层的 task 快照,避免用户必须关弹层重开才能看到新值
@@ -442,7 +452,7 @@ async function refreshAssigneesAfterMutation() {
assigneesLoading.value = false; assigneesLoading.value = false;
currentAssignees.value = error || !assigneeData ? [] : assigneeData; currentAssignees.value = error || !assigneeData ? [] : assigneeData;
await getData(); await refreshTableData();
} }
async function loadExecutionAssigneeOptions() { async function loadExecutionAssigneeOptions() {
@@ -483,7 +493,7 @@ async function confirmDeleteTask(payload: { name: string; confirmText: string; r
window.$message?.success('删除成功'); window.$message?.success('删除成功');
deleteTaskDialogVisible.value = false; deleteTaskDialogVisible.value = false;
deleteTaskTarget.value = null; deleteTaskTarget.value = null;
await Promise.all([getData(), loadTaskStatusBoard()]); await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
} }
async function loadTaskStatusBoard() { async function loadTaskStatusBoard() {
@@ -517,6 +527,13 @@ watch(
}, },
{ immediate: true } { immediate: true }
); );
// 看板期间的搜索/变更不会触发表格分页;切回表格时按当前过滤补拉一次首页,避免表格数据陈旧
watch(viewMode, async mode => {
if (mode === 'table' && canLoadTasks.value) {
await getDataByPage(1);
}
});
</script> </script>
<template> <template>
@@ -562,24 +579,17 @@ watch(
/> />
<TaskBoardView <TaskBoardView
v-else v-else
:data="data" :project-id="projectId"
:loading="loading" :execution-id="executionId"
:status-board="taskStatusBoard" :status-board="taskStatusBoard"
:base-params="createStatusBoardParams()"
@detail="handleDetail" @detail="handleDetail"
@edit="handleEdit" @edit="handleEdit"
@report="handleReport"
@status-action="handleStatusAction" @status-action="handleStatusAction"
@delete="openDeleteTaskDialog"
/> />
<div v-if="execution && viewMode === 'board' && mobilePagination.total" class="task-workspace__board-pagination">
<ElPagination
background
layout="total, sizes, prev, pager, next"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
<TaskOperateDialog <TaskOperateDialog
ref="taskOperateDialogRef" ref="taskOperateDialogRef"
v-model:visible="operateVisible" v-model:visible="operateVisible"
@@ -694,11 +704,6 @@ watch(
font-size: 16px; font-size: 16px;
} }
.task-workspace__board-pagination {
display: flex;
justify-content: flex-end;
}
.task-workspace__empty { .task-workspace__empty {
flex: 1; flex: 1;
border: 1px dashed rgb(203 213 225 / 92%); border: 1px dashed rgb(203 213 225 / 92%);

View File

@@ -215,11 +215,12 @@ export function isTaskLeafInList(row: Api.Project.ProjectTask, allRows: Api.Proj
} }
/** /**
* 是否对该任务行展示「填报」入口(与后端 2026-05-11 工时填报矩阵对齐 * 是否对该任务行展示「填报」入口(与后端工时填报矩阵 §4.8.4 对齐 + 业务事实修正
* - 已登录、当前页叶子、且当前用户是 owner / 活跃协人为基础门槛 * - 已登录、当前页叶子、且当前用户是 owner / 活跃协人为基础门槛
* - 待开始 / 进行中:负责人、协办人均可 * - 待开始pending:负责人、协办人均可——首次填工时是 auto_start 触发口(无独立"开始"按钮)
* - 进行中active负责人、协办人均可
* - 已完成completed负责人、协办人均可补录 / 修正用于报表补齐;后端不回写 task.progressRate
* - 已暂停 / 已取消:双方均拒 * - 已暂停 / 已取消:双方均拒
* - 已完成:仅协办人可补登历史工时;负责人拦截(避免负责人填工时把进度改回低值)
*/ */
export function canReportTaskWorklog( export function canReportTaskWorklog(
row: Api.Project.ProjectTask, row: Api.Project.ProjectTask,
@@ -239,17 +240,7 @@ export function canReportTaskWorklog(
return false; return false;
} }
switch (row.statusCode) { return row.statusCode === 'pending' || row.statusCode === 'active' || row.statusCode === 'completed';
case 'pending':
case 'active':
return true;
case 'completed':
return !isOwner && isActiveAssignee;
case 'paused':
case 'cancelled':
default:
return false;
}
} }
type TaskAssigneeActionType = Api.Project.TaskAssigneeActionType; type TaskAssigneeActionType = Api.Project.TaskAssigneeActionType;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,785 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProject, fetchGetProjectMembers, fetchGetProjectSettings } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useCurrentProject } from '../../shared/use-current-project';
import {
buildProjectHomepageBanner,
buildProjectHomepageTimeline,
buildProjectScheduleOverview,
buildProjectTeamOverview,
getProjectHomepageExtensionModules
} from './homepage';
import { projectHomepageExtensionMock } from './mock';
defineOptions({ name: 'ProjectOverview' });
const { currentObjectId, currentProject } = useCurrentProject();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
const pageLoading = ref(false);
const projectDetail = ref<Api.Project.Project | null>(null);
const settings = ref<Api.Project.ProjectSettings | null>(null);
const members = ref<Api.Project.ProjectMember[]>([]);
const latestActivityTime = ref('');
const timelineItems = computed(() => buildProjectHomepageTimeline(projectDetail.value, settings.value, members.value));
const scheduleOverview = computed(() => buildProjectScheduleOverview(projectDetail.value));
const teamOverview = computed(() => buildProjectTeamOverview(members.value));
const homepageBanner = computed(() =>
buildProjectHomepageBanner({
project: projectDetail.value,
settings: settings.value,
members: members.value,
latestActivityTime: latestActivityTime.value
})
);
const extensionModules = computed(() => getProjectHomepageExtensionModules(projectHomepageExtensionMock));
const directionLabel = computed(() => getDirectionLabel(homepageBanner.value.identity.directionCode, '--'));
const projectTypeLabel = computed(() => getProjectTypeLabel(homepageBanner.value.identity.projectType, '--'));
const bannerFacts = computed(() => [
{
label: '项目方向',
value: directionLabel.value,
fullWidth: false
},
{
label: '项目类型',
value: projectTypeLabel.value,
fullWidth: false
},
...homepageBanner.value.identity.facts
]);
const progressValue = computed(() => projectDetail.value?.progressRate ?? 0);
const bannerStatusClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode ? `project-homepage-banner--${statusCode}` : 'project-homepage-banner--default';
});
const bannerStatusWordClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode
? `project-homepage-banner__status-word--${statusCode}`
: 'project-homepage-banner__status-word--default';
});
async function loadOverviewData(objectId: string) {
pageLoading.value = true;
try {
const [projectResult, settingsResult, membersResult] = await Promise.all([
fetchGetProject(objectId),
fetchGetProjectSettings(objectId),
fetchGetProjectMembers(objectId)
]);
projectDetail.value = projectResult.error ? null : projectResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
latestActivityTime.value = timelineItems.value[0]?.time || '';
} finally {
pageLoading.value = false;
}
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
projectDetail.value = null;
settings.value = null;
members.value = [];
latestActivityTime.value = '';
return;
}
await loadOverviewData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="project-homepage">
<section class="project-homepage-banner" :class="bannerStatusClass">
<div class="project-homepage-banner__identity">
<div class="project-homepage-banner__title-group">
<div class="project-homepage-banner__title-main min-w-0">
<div class="project-homepage-banner__title-row">
<h1 class="project-homepage-banner__title">
{{ homepageBanner.identity.name || currentProject?.projectName || '--' }}
</h1>
<span class="project-homepage-banner__status-word" :class="bannerStatusWordClass">
{{ homepageBanner.identity.statusLabel }}
</span>
</div>
<div class="project-homepage-banner__subtitle">
<span class="project-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
<p v-if="homepageBanner.identity.description" class="project-homepage-banner__description">
{{ homepageBanner.identity.description }}
</p>
</div>
</div>
</div>
<div class="project-homepage-banner__facts">
<div
v-for="item in bannerFacts"
:key="item.label"
class="project-homepage-banner__fact"
:class="{ 'project-homepage-banner__fact--full': item.fullWidth }"
>
<span class="project-homepage-banner__fact-label">{{ item.label }}</span>
<strong class="project-homepage-banner__fact-value">{{ item.value }}</strong>
</div>
</div>
</div>
<div class="project-homepage-banner__metrics">
<article v-for="item in homepageBanner.metrics" :key="item.label" class="project-homepage-banner__metric">
<span class="project-homepage-banner__metric-label">{{ item.label }}</span>
<strong class="project-homepage-banner__metric-value">{{ item.value }}</strong>
</article>
</div>
</section>
<section class="project-homepage-main">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目动态时间线</h3>
<p class="project-homepage-panel__desc">
先展示项目创建状态动作实际日期和团队变化后续可替换为专用动态接口
</p>
</div>
</template>
<div v-if="timelineItems.length" class="project-homepage-timeline">
<article v-for="item in timelineItems" :key="item.key" class="project-homepage-timeline__item">
<div class="project-homepage-timeline__rail">
<span class="project-homepage-timeline__dot" :class="`project-homepage-timeline__dot--${item.tone}`" />
<span class="project-homepage-timeline__line" />
</div>
<div class="project-homepage-timeline__content">
<div class="project-homepage-timeline__meta">
<ElTag effect="plain" size="small">{{ item.tag }}</ElTag>
<span class="project-homepage-timeline__time">{{ item.time }}</span>
</div>
<p class="project-homepage-timeline__sentence">
<strong class="project-homepage-timeline__headline">{{ item.title }}</strong>
<span>{{ item.content }}</span>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="当前暂无可展示的项目动态" :image-size="88" />
</ElCard>
<div class="project-homepage-main__aside">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">计划进展概览</h3>
<p class="project-homepage-panel__desc">先看当前进度计划周期和实际执行日期是否已经闭环</p>
</div>
</template>
<div class="project-homepage-schedule">
<div class="project-homepage-schedule__progress">
<strong>{{ progressValue }}%</strong>
<ElProgress
:percentage="progressValue"
:stroke-width="8"
:show-text="false"
:color="progressValue >= 100 ? '#10b981' : progressValue >= 50 ? '#3b82f6' : '#6366f1'"
/>
</div>
<div class="project-homepage-summary-metrics">
<article
v-for="item in scheduleOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div class="project-homepage-schedule__dates">
<div v-for="item in scheduleOverview.dates" :key="item.label" class="project-homepage-schedule__date">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</ElCard>
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目团队概览</h3>
<p class="project-homepage-panel__desc">承接当前成员规模负责人和角色结构和设置页团队维护分开表达</p>
</div>
</template>
<div class="project-homepage-team">
<div class="project-homepage-summary-metrics">
<article
v-for="item in teamOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div v-if="teamOverview.roles.length" class="project-homepage-team__roles">
<div v-for="item in teamOverview.roles" :key="item.label" class="project-homepage-team__role">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="72" />
</div>
</ElCard>
</div>
</section>
<section class="project-homepage-extension">
<ElCard v-for="module in extensionModules" :key="module.key" class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">{{ module.title }}</h3>
<p class="project-homepage-panel__desc">{{ module.description }}</p>
</div>
</template>
<div class="project-homepage-extension__list">
<div v-for="item in module.items" :key="item" class="project-homepage-extension__item">
<span class="project-homepage-extension__dot" />
<span>{{ item }}</span>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.project-homepage {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 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%));
}
.project-homepage-banner--default {
border-color: rgb(226 232 240 / 92%);
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%));
}
.project-homepage-banner--pending,
.project-homepage-banner--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--active,
.project-homepage-banner--completed {
border-color: rgb(167 243 208 / 88%);
background:
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--paused {
border-color: rgb(253 230 138 / 90%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--cancelled {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner__identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner__title-group {
display: flex;
align-items: flex-start;
gap: 12px;
}
.project-homepage-banner__title-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
}
.project-homepage-banner__title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 14px;
}
.project-homepage-banner__code {
margin: 0;
color: rgb(14 116 144 / 92%);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.project-homepage-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 34px;
line-height: 1.15;
letter-spacing: 0;
}
.project-homepage-banner__subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.project-homepage-banner__description {
margin: 0;
min-width: 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.8;
}
.project-homepage-banner__status-word {
flex-shrink: 0;
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
user-select: none;
}
.project-homepage-banner__status-word--default {
color: rgb(148 163 184 / 48%);
}
.project-homepage-banner__status-word--pending,
.project-homepage-banner__status-word--archived {
color: transparent;
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
background-clip: text;
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--active,
.project-homepage-banner__status-word--completed {
color: transparent;
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--paused {
color: transparent;
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--cancelled {
color: transparent;
background: linear-gradient(180deg, rgb(244 63 94 / 94%), rgb(251 113 133 / 68%));
background-clip: text;
text-shadow: 0 10px 24px rgb(244 63 94 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__fact {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.project-homepage-banner__fact--full {
grid-column: 1 / -1;
align-items: flex-start;
}
.project-homepage-banner__fact-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
white-space: nowrap;
}
.project-homepage-banner__fact-value {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
text-align: right;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: 72%;
text-align: left;
}
.project-homepage-banner__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__metric {
display: flex;
min-height: 112px;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 18px;
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%));
}
.project-homepage-banner__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-banner__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.1;
letter-spacing: 0;
word-break: break-word;
}
.project-homepage-main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-main__aside {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-panel {
overflow: hidden;
}
.project-homepage-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.project-homepage-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.project-homepage-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-timeline__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.project-homepage-timeline__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.project-homepage-timeline__dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.project-homepage-timeline__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.project-homepage-timeline__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.project-homepage-timeline__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.project-homepage-timeline__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.project-homepage-timeline__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.project-homepage-timeline__line {
flex: 1;
width: 2px;
min-height: 30px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.project-homepage-timeline__item:last-child .project-homepage-timeline__line {
opacity: 0;
}
.project-homepage-timeline__content {
padding: 12px 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.project-homepage-timeline__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.project-homepage-timeline__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.project-homepage-timeline__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.65;
}
.project-homepage-timeline__headline {
margin-right: 6px;
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.project-homepage-schedule,
.project-homepage-team {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-schedule__progress {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-schedule__progress strong {
color: rgb(15 23 42 / 98%);
font-size: 36px;
line-height: 1.1;
}
.project-homepage-summary-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-summary-metrics__item {
display: flex;
min-height: 100px;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-summary-metrics__label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-summary-metrics__value {
color: rgb(15 23 42 / 98%);
font-size: 22px;
line-height: 1.2;
word-break: break-word;
}
.project-homepage-schedule__dates,
.project-homepage-team__roles {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-schedule__date,
.project-homepage-team__role {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 14px;
background-color: rgb(255 255 255 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
}
.project-homepage-schedule__date strong,
.project-homepage-team__role strong {
color: rgb(15 23 42 / 98%);
font-size: 18px;
}
.project-homepage-extension {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-extension__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-extension__item {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
line-height: 1.7;
}
.project-homepage-extension__dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: 999px;
background-color: rgb(14 116 144 / 88%);
}
@media (width <= 1280px) {
.project-homepage-banner,
.project-homepage-main,
.project-homepage-extension {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.project-homepage-banner {
padding: 18px;
}
.project-homepage-banner__title-row {
flex-wrap: wrap;
}
.project-homepage-banner__title {
font-size: 28px;
}
.project-homepage-banner__status-word {
font-size: 22px;
}
.project-homepage-banner__facts,
.project-homepage-banner__metrics,
.project-homepage-summary-metrics {
grid-template-columns: 1fr;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: none;
}
}
</style>

View File

@@ -37,7 +37,7 @@ defineOptions({ name: 'UserManagementRelation' });
* *
* @param fromUserIndex 是否不是从管理链路 index 页面访问(从 user 页面访问时为 true * @param fromUserIndex 是否不是从管理链路 index 页面访问(从 user 页面访问时为 true
* @param deptId 部门 ID * @param deptId 部门 ID
* @param orgType 组织类型company/dept/direction/team * @param orgType 组织类型company/dept/function/direction/team
*/ */
interface userQuery { interface userQuery {
fromUserIndex?: boolean; fromUserIndex?: boolean;

View File

@@ -46,6 +46,7 @@ const title = computed(() => {
const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [ const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [
{ value: 'company', label: 'page.system.user.orgType.company' }, { value: 'company', label: 'page.system.user.orgType.company' },
{ value: 'dept', label: 'page.system.user.orgType.dept' }, { value: 'dept', label: 'page.system.user.orgType.dept' },
{ value: 'function', label: 'page.system.user.orgType.function' },
{ value: 'direction', label: 'page.system.user.orgType.direction' }, { value: 'direction', label: 'page.system.user.orgType.direction' },
{ value: 'team', label: 'page.system.user.orgType.team' } { value: 'team', label: 'page.system.user.orgType.team' }
]; ];

View File

@@ -2,10 +2,11 @@
import { markRaw, ref, watch } from 'vue'; import { markRaw, ref, watch } from 'vue';
import type { TreeInstance } from 'element-plus'; import type { TreeInstance } from 'element-plus';
import { $t } from '@/locales'; import { $t } from '@/locales';
import IconMdiAccountGroup from '~icons/mdi/account-group'; import IconMdiAccountGroupOutline from '~icons/mdi/account-group-outline';
import IconMdiDomain from '~icons/mdi/domain'; import IconMdiArrowDecisionOutline from '~icons/mdi/arrow-decision-outline';
import IconMdiOfficeBuilding from '~icons/mdi/office-building'; import IconMdiBriefcaseVariantOutline from '~icons/mdi/briefcase-variant-outline';
import IconMdiSourceBranch from '~icons/mdi/source-branch'; import IconMdiOfficeBuildingOutline from '~icons/mdi/office-building-outline';
import IconMdiSitemapOutline from '~icons/mdi/sitemap-outline';
defineOptions({ name: 'UserOrgPanel' }); defineOptions({ name: 'UserOrgPanel' });
@@ -43,10 +44,11 @@ function filterNode(value: string, nodeData: Api.SystemManage.Dept) {
function getOrgIcon(orgType: Api.SystemManage.DeptOrgType) { function getOrgIcon(orgType: Api.SystemManage.DeptOrgType) {
const iconMap: Record<Api.SystemManage.DeptOrgType, object> = { const iconMap: Record<Api.SystemManage.DeptOrgType, object> = {
company: markRaw(IconMdiDomain), company: markRaw(IconMdiOfficeBuildingOutline),
dept: markRaw(IconMdiOfficeBuilding), dept: markRaw(IconMdiSitemapOutline),
direction: markRaw(IconMdiSourceBranch), function: markRaw(IconMdiBriefcaseVariantOutline),
team: markRaw(IconMdiAccountGroup) direction: markRaw(IconMdiArrowDecisionOutline),
team: markRaw(IconMdiAccountGroupOutline)
}; };
return iconMap[orgType]; return iconMap[orgType];

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="待我处理的工单" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -0,0 +1,3 @@
<template>
<LookForward title="我提交的工单" subtitle="功能建设中,敬请期待" />
</template>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -0,0 +1,347 @@
import dayjs from 'dayjs';
export type WorkbenchTrend = 'up' | 'down' | 'flat';
export type WorkbenchTodoCategory = 'review' | 'task' | 'ticket' | 'mention';
export type WorkbenchTodoTimeBucket = 'all' | 'today' | 'week' | 'overdue';
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: {
count: number;
diffFromYesterday: number;
};
/** 我负责的任务 */
task: {
count: number;
diffFromYesterday: number;
};
/** 我参与的项目 */
project: {
count: number;
activeCount: number;
};
/** 我负责的需求 */
requirement: {
count: number;
pendingReview: number;
};
}
export interface WorkbenchKpiCard {
key: 'todo' | 'task' | 'project' | 'requirement';
label: string;
value: number;
trend: WorkbenchTrend;
trendText: string;
hint: string;
icon: string;
tone: 'sky' | 'emerald' | 'amber' | 'rose';
}
export interface WorkbenchTodoItemSource {
id: string;
category: WorkbenchTodoCategory;
title: string;
/** 截止时间ISO 字符串 */
deadline: string | null;
/** 来源(提交人/项目名/工单号) */
source: string;
/** 是否逾期 */
overdue?: boolean;
/** 是否高优先级 */
highPriority?: boolean;
/** 跳转路由 key可选未配则不跳转 */
routeKey?: string;
}
export interface WorkbenchTodoItem extends Omit<WorkbenchTodoItemSource, 'deadline'> {
deadlineLabel: string;
/** 相对截止天数:负数表示已逾期 */
remainingDays: number | null;
categoryLabel: string;
categoryTone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
}
export interface WorkbenchActivityItemSource {
id: string;
actor: string;
action: string;
/** 目标对象(需求/任务/工单/项目名等) */
target: string;
/** 目标对象的种类,用于颜色 */
targetKind: 'requirement' | 'task' | 'ticket' | 'project' | 'product';
/** 时间ISO */
time: string;
/** 是否 @ 了当前用户 */
mentioned?: boolean;
}
export interface WorkbenchActivityItem extends Omit<WorkbenchActivityItemSource, 'time'> {
timeLabel: string;
relativeLabel: string;
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
}
export interface WorkbenchProjectItemSource {
id: string;
name: string;
code: string;
status: WorkbenchProjectStatus;
/** 我的角色 */
myRole: string;
/** 进度百分比 0-100 */
progress: number;
/** 我负责的任务数 */
myTaskCount: number;
/** 我负责的待处理任务数 */
myPendingTaskCount: number;
/** 最近活动时间ISO */
lastActiveTime: string;
}
export interface WorkbenchProjectItem extends Omit<WorkbenchProjectItemSource, 'lastActiveTime'> {
statusLabel: string;
statusTone: 'sky' | 'emerald' | 'amber';
progress: number;
lastActiveLabel: string;
}
const todoCategoryMeta: Record<
WorkbenchTodoCategory,
{ label: string; tone: WorkbenchTodoItem['categoryTone']; icon: string }
> = {
review: { label: '需求评审', tone: 'sky', icon: 'mdi:file-search-outline' },
task: { label: '任务', tone: 'emerald', icon: 'mdi:checkbox-marked-circle-outline' },
ticket: { label: '工单', tone: 'amber', icon: 'mdi:ticket-confirmation-outline' },
mention: { label: '@ 提及', tone: 'violet', icon: 'mdi:at' }
};
const activityToneMap: Record<WorkbenchActivityItemSource['targetKind'], WorkbenchActivityItem['tone']> = {
requirement: 'sky',
task: 'emerald',
ticket: 'amber',
project: 'violet',
product: 'rose'
};
const projectStatusMeta: Record<WorkbenchProjectStatus, { label: string; tone: WorkbenchProjectItem['statusTone'] }> = {
active: { label: '进行中', tone: 'emerald' },
preview: { label: '试运行', tone: 'sky' },
paused: { label: '已暂停', tone: 'amber' }
};
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.min(100, Math.max(0, Math.round(value)));
}
function formatRelative(value: string) {
const target = dayjs(value);
if (!target.isValid()) return '--';
const now = dayjs();
const diffMinutes = now.diff(target, 'minute');
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
const diffHours = now.diff(target, 'hour');
if (diffHours < 24) return `${diffHours} 小时前`;
const diffDays = now.diff(target, 'day');
if (diffDays < 7) return `${diffDays} 天前`;
return target.format('MM-DD HH:mm');
}
function formatDeadline(value: string | null) {
if (!value) return '不限';
const target = dayjs(value);
if (!target.isValid()) return '不限';
const now = dayjs().startOf('day');
const start = target.startOf('day');
const days = start.diff(now, 'day');
if (days === 0) return `今日 ${target.format('HH:mm')}`;
if (days === 1) return `明日 ${target.format('HH:mm')}`;
if (days === -1) return `昨日 ${target.format('HH:mm')}`;
if (days > 0 && days <= 7) return `${days} 天后 ${target.format('MM-DD')}`;
if (days < 0) return `逾期 ${Math.abs(days)}`;
return target.format('YYYY-MM-DD');
}
function getRemainingDays(value: string | null) {
if (!value) return null;
const target = dayjs(value);
if (!target.isValid()) return 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}` };
if (diff < 0) return { trend: 'down', text: `较昨日 ${diff}` };
return { trend: 'flat', text: '与昨日持平' };
}
const todoTrend = diffTrend(source.todo.diffFromYesterday);
const taskTrend = diffTrend(source.task.diffFromYesterday);
return [
{
key: 'todo',
label: '我的待办',
value: source.todo.count,
trend: todoTrend.trend,
trendText: todoTrend.text,
hint: '需要我处理的评审、任务、工单与 @ 提醒合计',
icon: 'mdi:clipboard-text-clock-outline',
tone: 'sky'
},
{
key: 'task',
label: '我负责的任务',
value: source.task.count,
trend: taskTrend.trend,
trendText: taskTrend.text,
hint: '当前由我负责的进行中任务',
icon: 'mdi:checkbox-marked-circle-outline',
tone: 'emerald'
},
{
key: 'project',
label: '我参与的项目',
value: source.project.count,
trend: 'flat',
trendText: `进行中 ${source.project.activeCount}`,
hint: '我作为成员参与的项目总数',
icon: 'mdi:briefcase-outline',
tone: 'amber'
},
{
key: 'requirement',
label: '我负责的需求',
value: source.requirement.count,
trend: 'flat',
trendText: `待评审 ${source.requirement.pendingReview}`,
hint: '由我担任产品经理或负责人的需求',
icon: 'mdi:file-document-multiple-outline',
tone: 'rose'
}
];
}
export function buildWorkbenchTodoItems(source: readonly WorkbenchTodoItemSource[]): WorkbenchTodoItem[] {
return [...source]
.sort((left, right) => {
const leftValue = left.deadline ? dayjs(left.deadline).valueOf() : Number.POSITIVE_INFINITY;
const rightValue = right.deadline ? dayjs(right.deadline).valueOf() : Number.POSITIVE_INFINITY;
return leftValue - rightValue;
})
.map(item => {
const meta = todoCategoryMeta[item.category];
return {
...item,
deadlineLabel: formatDeadline(item.deadline),
remainingDays: getRemainingDays(item.deadline),
categoryLabel: meta.label,
categoryTone: meta.tone
} satisfies WorkbenchTodoItem;
});
}
export function filterWorkbenchTodoItems(items: readonly WorkbenchTodoItem[], bucket: WorkbenchTodoTimeBucket) {
if (bucket === 'all') return [...items];
if (bucket === 'overdue') return items.filter(item => item.remainingDays !== null && item.remainingDays < 0);
if (bucket === 'today') return items.filter(item => item.remainingDays === 0);
// bucket === 'week'
return items.filter(item => item.remainingDays !== null && item.remainingDays >= 0 && item.remainingDays <= 7);
}
export function buildWorkbenchActivityItems(source: readonly WorkbenchActivityItemSource[]): WorkbenchActivityItem[] {
return [...source]
.filter(item => dayjs(item.time).isValid())
.sort((left, right) => dayjs(right.time).valueOf() - dayjs(left.time).valueOf())
.map(item => ({
...item,
timeLabel: dayjs(item.time).format('YYYY-MM-DD HH:mm'),
relativeLabel: formatRelative(item.time),
tone: activityToneMap[item.targetKind]
}));
}
export function buildWorkbenchProjectItems(source: readonly WorkbenchProjectItemSource[]): WorkbenchProjectItem[] {
return source.map(item => {
const meta = projectStatusMeta[item.status];
return {
...item,
statusLabel: meta.label,
statusTone: meta.tone,
progress: clampPercent(item.progress),
lastActiveLabel: formatRelative(item.lastActiveTime)
} satisfies WorkbenchProjectItem;
});
}
export function getGreeting(hour: number = dayjs().hour()) {
if (hour < 6) return '凌晨好';
if (hour < 11) return '早上好';
if (hour < 13) return '中午好';
if (hour < 18) return '下午好';
if (hour < 22) return '晚上好';
return '夜深了';
}
export function getTodayLabel() {
const today = dayjs();
const weekdayMap = ['日', '一', '二', '三', '四', '五', '六'];
return `今天 ${today.format('YYYY-MM-DD')} 星期${weekdayMap[today.day()]}`;
}

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
buildWorkbenchActivityItems,
buildWorkbenchBannerSummary,
buildWorkbenchKpiCards,
buildWorkbenchProjectItems,
buildWorkbenchTodoItems
} from './homepage';
import {
workbenchActivityMock,
workbenchBannerSummaryMock,
workbenchKpiMock,
workbenchProjectMock,
workbenchTodoMock
} from './mock';
import WorkbenchBanner from './modules/workbench-banner.vue';
import WorkbenchKpi from './modules/workbench-kpi.vue';
import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue';
import WorkbenchActivityPanel from './modules/workbench-activity-panel.vue';
import WorkbenchProjectGrid from './modules/workbench-project-grid.vue';
defineOptions({ name: 'Workbench' });
const bannerSummary = computed(() => buildWorkbenchBannerSummary(workbenchBannerSummaryMock));
const kpiCards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
const todoItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const activityItems = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
</script>
<template>
<div class="workbench">
<WorkbenchBanner :summary="bannerSummary" />
<WorkbenchKpi :cards="kpiCards" />
<section class="workbench__main">
<WorkbenchTodoPanel :items="todoItems" />
<WorkbenchActivityPanel :items="activityItems" />
</section>
<WorkbenchProjectGrid :items="projectItems" />
</div>
</template>
<style scoped>
.workbench {
display: flex;
flex-direction: column;
gap: 16px;
}
.workbench__main {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 16px;
}
@media (width <= 1280px) {
.workbench__main {
grid-template-columns: 1fr;
}
}
</style>

194
src/views/workbench/mock.ts Normal file
View File

@@ -0,0 +1,194 @@
import dayjs from 'dayjs';
import type {
WorkbenchActivityItemSource,
WorkbenchBannerSummarySource,
WorkbenchKpiSource,
WorkbenchProjectItemSource,
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 },
project: { count: 3, activeCount: 2 },
requirement: { count: 6, pendingReview: 2 }
} satisfies WorkbenchKpiSource;
export const workbenchTodoMock = [
{
id: 'todo-1',
category: 'review',
title: '订单导出 V2 需求评审',
deadline: iso(now.add(2, 'day').hour(18).minute(0)),
source: '张三 · 收银台 V3',
highPriority: true,
routeKey: 'product_list'
},
{
id: 'todo-2',
category: 'task',
title: '支付回调接口联调',
deadline: iso(now.add(1, 'day').hour(17).minute(30)),
source: '收银台 V3 · 后端联调',
routeKey: 'project_list'
},
{
id: 'todo-3',
category: 'ticket',
title: '工单 #1024 等待我处理',
deadline: iso(now.hour(20).minute(0)),
source: '王五 · 客户反馈',
routeKey: 'ticket'
},
{
id: 'todo-4',
category: 'mention',
title: '需求「会员等级」中 @ 了你',
deadline: null,
source: '李四 · 会员中心',
routeKey: 'product_list'
},
{
id: 'todo-5',
category: 'task',
title: '订单中心结算文档评审',
deadline: iso(now.add(4, 'day').hour(15).minute(0)),
source: '订单中心 · 文档',
routeKey: 'project_list'
},
{
id: 'todo-6',
category: 'review',
title: 'API 返回结构调整评审',
deadline: iso(now.subtract(1, 'day').hour(18).minute(0)),
source: '赵六 · 收银台 V3',
overdue: true,
highPriority: true,
routeKey: 'product_list'
},
{
id: 'todo-7',
category: 'task',
title: '会员等级提示文案校对',
deadline: iso(now.add(3, 'day').hour(11).minute(0)),
source: '会员中心 · 文案',
routeKey: 'project_list'
},
{
id: 'todo-8',
category: 'ticket',
title: '工单 #1018 用户登录异常',
deadline: iso(now.add(1, 'day').hour(10).minute(0)),
source: '客户支持 · 紧急',
highPriority: true,
routeKey: 'ticket'
}
] satisfies WorkbenchTodoItemSource[];
export const workbenchActivityMock = [
{
id: 'act-1',
actor: '张三',
action: '提交了需求评审申请',
target: '订单导出 V2',
targetKind: 'requirement',
time: iso(now.subtract(10, 'minute'))
},
{
id: 'act-2',
actor: '李四',
action: '完成了任务',
target: '支付回调接口联调',
targetKind: 'task',
time: iso(now.subtract(2, 'hour'))
},
{
id: 'act-3',
actor: '李四',
action: '在需求中 @ 了你',
target: '会员等级',
targetKind: 'requirement',
time: iso(now.subtract(1, 'day').hour(17).minute(23)),
mentioned: true
},
{
id: 'act-4',
actor: '王五',
action: '提交了工单',
target: '#1024 · 客户反馈',
targetKind: 'ticket',
time: iso(now.subtract(1, 'day').hour(15).minute(8))
},
{
id: 'act-5',
actor: '赵六',
action: '把项目状态调整为',
target: '试运行(订单中心)',
targetKind: 'project',
time: iso(now.subtract(2, 'day').hour(10).minute(0))
},
{
id: 'act-6',
actor: '系统',
action: '提醒:你有 1 项任务即将逾期',
target: 'API 返回结构调整评审',
targetKind: 'requirement',
time: iso(now.subtract(3, 'day').hour(9).minute(30))
},
{
id: 'act-7',
actor: '钱七',
action: '更新了产品资料',
target: '收银台 V3 · 定位说明',
targetKind: 'product',
time: iso(now.subtract(4, 'day').hour(16).minute(45))
}
] satisfies WorkbenchActivityItemSource[];
export const workbenchProjectMock = [
{
id: 'prj-1',
name: '收银台 V3',
code: 'CASHIER-V3',
status: 'active',
myRole: '项目负责人',
progress: 72,
myTaskCount: 6,
myPendingTaskCount: 2,
lastActiveTime: iso(now.subtract(35, 'minute'))
},
{
id: 'prj-2',
name: '会员中心',
code: 'MEMBER',
status: 'active',
myRole: '后端负责人',
progress: 58,
myTaskCount: 4,
myPendingTaskCount: 1,
lastActiveTime: iso(now.subtract(3, 'hour'))
},
{
id: 'prj-3',
name: '订单中心',
code: 'ORDER-CENTER',
status: 'preview',
myRole: '产品经理',
progress: 95,
myTaskCount: 4,
myPendingTaskCount: 0,
lastActiveTime: iso(now.subtract(2, 'day').hour(10))
}
] satisfies WorkbenchProjectItemSource[];

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { WorkbenchActivityItem } from '../homepage';
defineOptions({ name: 'WorkbenchActivityPanel' });
interface Props {
items: WorkbenchActivityItem[];
}
defineProps<Props>();
</script>
<template>
<ElCard class="workbench-activity card-wrapper" shadow="never">
<template #header>
<div>
<h3 class="workbench-activity__title">最近动态</h3>
<p class="workbench-activity__desc">关注与我相关的需求任务工单变化与 @ 提醒</p>
</div>
</template>
<div v-if="items.length" class="workbench-activity__list">
<article v-for="item in items" :key="item.id" class="workbench-activity__item">
<div class="workbench-activity__rail">
<span class="workbench-activity__dot" :class="`workbench-activity__dot--${item.tone}`" />
<span class="workbench-activity__line" />
</div>
<div class="workbench-activity__body">
<div class="workbench-activity__meta">
<span class="workbench-activity__time" :title="item.timeLabel">{{ item.relativeLabel }}</span>
<span v-if="item.mentioned" class="workbench-activity__mention">@ 提醒</span>
</div>
<p class="workbench-activity__sentence">
<strong class="workbench-activity__actor">{{ item.actor }}</strong>
<span>{{ item.action }}</span>
<strong class="workbench-activity__target">{{ item.target }}</strong>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="暂无动态" :image-size="72" />
</ElCard>
</template>
<style scoped>
.workbench-activity {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-activity__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.workbench-activity__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
}
.workbench-activity__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-activity__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.workbench-activity__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.workbench-activity__dot {
width: 12px;
height: 12px;
border-radius: 999px;
margin-top: 8px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.workbench-activity__dot--sky {
background-color: rgb(14 165 233 / 92%);
}
.workbench-activity__dot--emerald {
background-color: rgb(5 150 105 / 92%);
}
.workbench-activity__dot--amber {
background-color: rgb(217 119 6 / 92%);
}
.workbench-activity__dot--rose {
background-color: rgb(225 29 72 / 92%);
}
.workbench-activity__dot--violet {
background-color: rgb(124 58 237 / 92%);
}
.workbench-activity__line {
flex: 1;
width: 2px;
min-height: 28px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 24%));
}
.workbench-activity__item:last-child .workbench-activity__line {
opacity: 0;
}
.workbench-activity__body {
padding: 6px 14px 14px;
min-width: 0;
}
.workbench-activity__meta {
display: flex;
align-items: center;
gap: 8px;
}
.workbench-activity__time {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.workbench-activity__mention {
padding: 1px 8px;
border-radius: 999px;
background-color: rgb(237 233 254 / 96%);
color: rgb(109 40 217 / 96%);
font-size: 11px;
font-weight: 600;
}
.workbench-activity__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.7;
}
.workbench-activity__actor {
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.workbench-activity__target {
color: rgb(14 116 144 / 96%);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { getGreeting, getTodayLabel } from '../homepage';
import type { WorkbenchBannerSummary } from '../homepage';
defineOptions({ name: 'WorkbenchBanner' });
interface Props {
summary: WorkbenchBannerSummary;
}
const props = defineProps<Props>();
const { routerPushByKey } = useRouterPush();
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 }
]);
function handleCreateRequirement() {
routerPushByKey('product_list');
}
function handleCreateTask() {
routerPushByKey('project_list');
}
</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>
<span class="workbench-banner__decor-word">RDMS</span>
</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>
<div class="workbench-banner__actions">
<ElButton type="primary" @click="handleCreateRequirement">
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
<span>新建需求</span>
</ElButton>
<ElButton @click="handleCreateTask">
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
<span>新建任务</span>
</ElButton>
</div>
</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>
</li>
</ul>
</div>
</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%));
}
.workbench-banner__identity {
display: flex;
flex-direction: column;
gap: 14px;
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;
}
.workbench-banner__decor-word {
color: transparent;
background: linear-gradient(180deg, rgb(14 116 144 / 92%), rgb(13 148 136 / 60%));
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 22px;
font-weight: 800;
letter-spacing: 0.32em;
text-shadow: 0 10px 24px rgb(14 116 144 / 14%);
user-select: none;
}
.workbench-banner__subtitle {
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 {
color: rgb(203 213 225 / 96%);
font-size: 18px;
user-select: none;
}
.workbench-banner__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.workbench-banner__btn-icon {
margin-right: 4px;
font-size: 16px;
}
.workbench-banner__rhythm {
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%));
}
.workbench-banner__rhythm-header {
display: flex;
align-items: baseline;
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__rhythm-rate {
color: rgb(5 150 105 / 94%);
font-size: 13px;
font-weight: 700;
}
.workbench-banner__rhythm-bar {
position: relative;
height: 8px;
border-radius: 999px;
background-color: rgb(226 232 240 / 80%);
overflow: hidden;
}
.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__rhythm-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.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__rhythm-item--emerald {
background-color: rgb(236 253 245 / 88%);
}
.workbench-banner__rhythm-item--sky {
background-color: rgb(240 249 255 / 88%);
}
.workbench-banner__rhythm-item--rose {
background-color: rgb(255 241 242 / 88%);
}
.workbench-banner__rhythm-item-label {
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) {
.workbench-banner {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.workbench-banner {
padding: 18px;
}
.workbench-banner__title {
font-size: 26px;
}
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { WorkbenchKpiCard } from '../homepage';
defineOptions({ name: 'WorkbenchKpi' });
interface Props {
cards: WorkbenchKpiCard[];
}
defineProps<Props>();
function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
if (trend === 'up') return 'mdi:arrow-top-right-thin';
if (trend === 'down') return 'mdi:arrow-bottom-right-thin';
return 'mdi:minus';
}
</script>
<template>
<section class="workbench-kpi">
<article
v-for="card in cards"
:key="card.key"
class="workbench-kpi__card"
:class="`workbench-kpi__card--${card.tone}`"
>
<div class="workbench-kpi__card-header">
<span class="workbench-kpi__card-label">{{ card.label }}</span>
<span class="workbench-kpi__card-icon">
<SvgIcon :icon="card.icon" />
</span>
</div>
<strong class="workbench-kpi__card-value">{{ card.value }}</strong>
<div class="workbench-kpi__card-trend" :class="`workbench-kpi__card-trend--${card.trend}`">
<SvgIcon :icon="getTrendIcon(card.trend)" class="workbench-kpi__card-trend-icon" />
<span>{{ card.trendText }}</span>
</div>
<p class="workbench-kpi__card-hint">{{ card.hint }}</p>
</article>
</section>
</template>
<style scoped>
.workbench-kpi {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.workbench-kpi__card {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
min-height: 148px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 96%));
overflow: hidden;
}
.workbench-kpi__card::after {
content: '';
position: absolute;
inset: -40% -30% auto auto;
width: 160px;
height: 160px;
border-radius: 50%;
opacity: 0.55;
pointer-events: none;
}
.workbench-kpi__card--sky::after {
background: radial-gradient(circle, rgb(14 165 233 / 22%), transparent 70%);
}
.workbench-kpi__card--emerald::after {
background: radial-gradient(circle, rgb(16 185 129 / 22%), transparent 70%);
}
.workbench-kpi__card--amber::after {
background: radial-gradient(circle, rgb(245 158 11 / 22%), transparent 70%);
}
.workbench-kpi__card--rose::after {
background: radial-gradient(circle, rgb(244 63 94 / 22%), transparent 70%);
}
.workbench-kpi__card-header {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1;
}
.workbench-kpi__card-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
font-weight: 600;
}
.workbench-kpi__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 10px;
background-color: rgb(255 255 255 / 88%);
font-size: 18px;
}
.workbench-kpi__card--sky .workbench-kpi__card-icon {
color: rgb(14 116 144 / 94%);
}
.workbench-kpi__card--emerald .workbench-kpi__card-icon {
color: rgb(5 150 105 / 94%);
}
.workbench-kpi__card--amber .workbench-kpi__card-icon {
color: rgb(217 119 6 / 94%);
}
.workbench-kpi__card--rose .workbench-kpi__card-icon {
color: rgb(225 29 72 / 94%);
}
.workbench-kpi__card-value {
position: relative;
z-index: 1;
color: rgb(15 23 42 / 98%);
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.02em;
}
.workbench-kpi__card-trend {
display: inline-flex;
align-items: center;
gap: 4px;
position: relative;
z-index: 1;
width: fit-content;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.workbench-kpi__card-trend--up {
color: rgb(5 150 105 / 96%);
background-color: rgb(236 253 245 / 96%);
}
.workbench-kpi__card-trend--down {
color: rgb(225 29 72 / 96%);
background-color: rgb(255 241 242 / 96%);
}
.workbench-kpi__card-trend--flat {
color: rgb(100 116 139 / 94%);
background-color: rgb(241 245 249 / 96%);
}
.workbench-kpi__card-trend-icon {
font-size: 14px;
}
.workbench-kpi__card-hint {
margin: 0;
position: relative;
z-index: 1;
color: rgb(100 116 139 / 88%);
font-size: 12px;
line-height: 1.6;
}
@media (width <= 1280px) {
.workbench-kpi {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.workbench-kpi {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
import { useRouterPush } from '@/hooks/common/router';
import type { WorkbenchProjectItem } from '../homepage';
defineOptions({ name: 'WorkbenchProjectGrid' });
interface Props {
items: WorkbenchProjectItem[];
}
defineProps<Props>();
const { routerPushByKey } = useRouterPush();
function handleEnterProjectList() {
routerPushByKey('project_list');
}
</script>
<template>
<ElCard class="workbench-project card-wrapper" shadow="never">
<template #header>
<div class="workbench-project__header">
<div>
<h3 class="workbench-project__title">我参与的项目</h3>
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
</div>
<ElButton type="primary" link @click="handleEnterProjectList">
<span>进入项目列表</span>
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton>
</div>
</template>
<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>
</div>
<span class="workbench-project__card-status" :class="`workbench-project__card-status--${item.statusTone}`">
{{ item.statusLabel }}
</span>
</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>
<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" />
</ElCard>
</template>
<style scoped>
.workbench-project {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-project__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.workbench-project__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.workbench-project__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
}
.workbench-project__more-icon {
margin-left: 4px;
font-size: 16px;
}
.workbench-project__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.workbench-project__card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 18px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 96%));
transition:
border-color 160ms ease,
transform 160ms ease;
}
.workbench-project__card:hover {
border-color: rgb(14 116 144 / 60%);
}
.workbench-project__card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.workbench-project__card-title-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.workbench-project__card-title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
line-height: 1.4;
}
.workbench-project__card-code {
color: rgb(100 116 139 / 92%);
font-size: 12px;
letter-spacing: 0.04em;
}
.workbench-project__card-status {
flex-shrink: 0;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.workbench-project__card-status--emerald {
background-color: rgb(220 252 231 / 96%);
color: rgb(5 150 105 / 96%);
}
.workbench-project__card-status--sky {
background-color: rgb(224 242 254 / 96%);
color: rgb(14 116 144 / 96%);
}
.workbench-project__card-status--amber {
background-color: rgb(254 243 199 / 96%);
color: rgb(180 83 9 / 96%);
}
.workbench-project__card-role {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 12px;
background-color: rgb(241 245 249 / 80%);
}
.workbench-project__card-role-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.workbench-project__card-role-value {
color: rgb(15 23 42 / 98%);
font-size: 13px;
font-weight: 600;
}
.workbench-project__progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.workbench-project__progress-header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.workbench-project__progress-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.workbench-project__progress-value {
color: rgb(15 23 42 / 98%);
font-size: 18px;
line-height: 1;
letter-spacing: -0.02em;
}
.workbench-project__progress-bar {
height: 6px;
border-radius: 999px;
background-color: rgb(226 232 240 / 88%);
overflow: hidden;
}
.workbench-project__progress-bar-inner {
height: 100%;
border-radius: 999px;
transition: width 240ms ease;
}
.workbench-project__progress-bar-inner--emerald {
background: linear-gradient(90deg, rgb(5 150 105 / 92%), rgb(16 185 129 / 86%));
}
.workbench-project__progress-bar-inner--sky {
background: linear-gradient(90deg, rgb(14 116 144 / 92%), rgb(14 165 233 / 86%));
}
.workbench-project__progress-bar-inner--amber {
background: linear-gradient(90deg, rgb(217 119 6 / 92%), rgb(245 158 11 / 86%));
}
.workbench-project__footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.workbench-project__footer-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-project__footer-block--right {
align-items: flex-end;
}
.workbench-project__footer-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.workbench-project__footer-value {
color: rgb(15 23 42 / 98%);
font-size: 14px;
font-weight: 600;
}
.workbench-project__footer-sub {
color: rgb(190 18 60 / 94%);
font-size: 12px;
font-weight: 600;
}
@media (width <= 1280px) {
.workbench-project__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.workbench-project__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,347 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { RouteKey } from '@elegant-router/types';
import { useRouterPush } from '@/hooks/common/router';
import { filterWorkbenchTodoItems } from '../homepage';
import type { WorkbenchTodoItem, WorkbenchTodoTimeBucket } from '../homepage';
defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props {
items: WorkbenchTodoItem[];
}
const props = defineProps<Props>();
const { routerPushByKey } = useRouterPush();
const activeBucket = ref<WorkbenchTodoTimeBucket>('all');
const buckets: Array<{ key: WorkbenchTodoTimeBucket; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'today', label: '今日' },
{ key: 'week', label: '本周' },
{ key: 'overdue', label: '逾期' }
];
const bucketCounts = computed(() => ({
all: props.items.length,
today: filterWorkbenchTodoItems(props.items, 'today').length,
week: filterWorkbenchTodoItems(props.items, 'week').length,
overdue: filterWorkbenchTodoItems(props.items, 'overdue').length
}));
const filteredItems = computed(() => filterWorkbenchTodoItems(props.items, activeBucket.value));
function handleClickItem(item: WorkbenchTodoItem) {
if (!item.routeKey) return;
routerPushByKey(item.routeKey as RouteKey);
}
function getDeadlineToneClass(item: WorkbenchTodoItem) {
if (item.overdue || (item.remainingDays !== null && item.remainingDays < 0)) {
return 'workbench-todo__deadline--rose';
}
if (item.remainingDays === 0) return 'workbench-todo__deadline--amber';
return 'workbench-todo__deadline--slate';
}
</script>
<template>
<ElCard class="workbench-todo card-wrapper" shadow="never">
<template #header>
<div class="workbench-todo__header">
<div class="workbench-todo__title-group">
<h3 class="workbench-todo__title">我的待办</h3>
<p class="workbench-todo__desc">需要我处理的需求评审任务工单与 @ 提醒</p>
</div>
<div class="workbench-todo__tabs">
<button
v-for="bucket in buckets"
:key="bucket.key"
type="button"
class="workbench-todo__tab"
:class="{ 'workbench-todo__tab--active': activeBucket === bucket.key }"
@click="activeBucket = bucket.key"
>
<span>{{ bucket.label }}</span>
<span class="workbench-todo__tab-count">{{ bucketCounts[bucket.key] }}</span>
</button>
</div>
</div>
</template>
<div v-if="filteredItems.length" class="workbench-todo__list">
<article
v-for="item in filteredItems"
:key="item.id"
class="workbench-todo__item"
:class="{ 'workbench-todo__item--clickable': Boolean(item.routeKey) }"
@click="handleClickItem(item)"
>
<div class="workbench-todo__leading">
<span class="workbench-todo__category" :class="`workbench-todo__category--${item.categoryTone}`">
{{ item.categoryLabel }}
</span>
<span v-if="item.highPriority" class="workbench-todo__priority"></span>
</div>
<div class="workbench-todo__body">
<h4 class="workbench-todo__item-title">{{ item.title }}</h4>
<div class="workbench-todo__meta">
<span class="workbench-todo__source">{{ item.source }}</span>
</div>
</div>
<div class="workbench-todo__trailing">
<span class="workbench-todo__deadline" :class="getDeadlineToneClass(item)">
{{ item.deadlineLabel }}
</span>
</div>
</article>
</div>
<ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" />
</ElCard>
</template>
<style scoped>
.workbench-todo {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-todo__header {
display: flex;
flex-direction: column;
gap: 14px;
}
.workbench-todo__title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-todo__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.workbench-todo__desc {
margin: 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
}
.workbench-todo__tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.workbench-todo__tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 999px;
background-color: rgb(255 255 255 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 13px;
cursor: pointer;
transition: all 160ms ease;
}
.workbench-todo__tab:hover {
border-color: rgb(14 116 144 / 64%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__tab--active {
border-color: rgb(14 116 144 / 92%);
background-color: rgb(14 116 144 / 96%);
color: white;
}
.workbench-todo__tab--active:hover {
color: white;
}
.workbench-todo__tab-count {
padding: 1px 6px;
border-radius: 999px;
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 11px;
font-weight: 600;
}
.workbench-todo__tab--active .workbench-todo__tab-count {
background-color: rgb(255 255 255 / 22%);
color: white;
}
.workbench-todo__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.workbench-todo__item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.workbench-todo__item--clickable {
cursor: pointer;
}
.workbench-todo__item--clickable:hover {
border-color: rgb(14 116 144 / 60%);
background-color: rgb(240 253 250 / 84%);
}
.workbench-todo__leading {
display: flex;
align-items: center;
gap: 8px;
}
.workbench-todo__category {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.workbench-todo__category--sky {
background-color: rgb(224 242 254 / 96%);
color: rgb(14 116 144 / 96%);
}
.workbench-todo__category--emerald {
background-color: rgb(220 252 231 / 96%);
color: rgb(5 150 105 / 96%);
}
.workbench-todo__category--amber {
background-color: rgb(254 243 199 / 96%);
color: rgb(180 83 9 / 96%);
}
.workbench-todo__category--rose {
background-color: rgb(255 228 230 / 96%);
color: rgb(190 18 60 / 96%);
}
.workbench-todo__category--violet {
background-color: rgb(237 233 254 / 96%);
color: rgb(109 40 217 / 96%);
}
.workbench-todo__priority {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
background-color: rgb(254 226 226 / 96%);
color: rgb(220 38 38 / 96%);
font-size: 11px;
font-weight: 800;
}
.workbench-todo__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-todo__item-title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 14px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workbench-todo__meta {
display: flex;
align-items: center;
gap: 8px;
}
.workbench-todo__source {
color: rgb(100 116 139 / 92%);
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workbench-todo__trailing {
display: flex;
align-items: center;
}
.workbench-todo__deadline {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.workbench-todo__deadline--slate {
background-color: rgb(241 245 249 / 96%);
color: rgb(71 85 105 / 94%);
}
.workbench-todo__deadline--amber {
background-color: rgb(254 243 199 / 96%);
color: rgb(180 83 9 / 96%);
}
.workbench-todo__deadline--rose {
background-color: rgb(255 228 230 / 96%);
color: rgb(190 18 60 / 96%);
}
@media (width <= 600px) {
.workbench-todo__item {
grid-template-columns: auto minmax(0, 1fr);
}
.workbench-todo__trailing {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
</style>