feat(projects): 1、增加空白页占位;2、调试已开发功能;
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'LookForward' });
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,7 +17,10 @@ defineOptions({ name: 'LookForward' });
|
||||
<SvgIcon local-icon="expectation" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +18,7 @@ function loginOrRegister() {
|
||||
toLogin();
|
||||
}
|
||||
|
||||
type DropdownKey = 'user-center' | 'logout';
|
||||
type DropdownKey = 'personal-center_my-profile' | 'logout';
|
||||
|
||||
type DropdownOption = {
|
||||
key: DropdownKey;
|
||||
@@ -29,8 +29,8 @@ type DropdownOption = {
|
||||
const options = computed(() => {
|
||||
const opts: DropdownOption[] = [
|
||||
{
|
||||
label: $t('common.userCenter'),
|
||||
key: 'user-center',
|
||||
label: $t('common.myProfile'),
|
||||
key: 'personal-center_my-profile',
|
||||
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
|
||||
trigger: 'Trigger',
|
||||
update: 'Update',
|
||||
updateSuccess: 'Update Success',
|
||||
userCenter: 'User Center',
|
||||
myProfile: 'My Profile',
|
||||
yesOrNo: {
|
||||
yes: 'Yes',
|
||||
no: 'No'
|
||||
@@ -158,7 +158,24 @@ const local: App.I18n.Schema = {
|
||||
404: 'Page Not Found',
|
||||
500: 'Server Error',
|
||||
'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_tab: 'Tab',
|
||||
'function_multi-tab': 'Multi Tab',
|
||||
@@ -495,6 +512,7 @@ const local: App.I18n.Schema = {
|
||||
orgType: {
|
||||
company: 'Company',
|
||||
dept: 'Department',
|
||||
function: 'Functional Department',
|
||||
direction: 'Direction',
|
||||
team: 'Team'
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
|
||||
trigger: '触发',
|
||||
update: '更新',
|
||||
updateSuccess: '更新成功',
|
||||
userCenter: '个人中心',
|
||||
myProfile: '个人信息',
|
||||
yesOrNo: {
|
||||
yes: '是',
|
||||
no: '否'
|
||||
@@ -158,7 +158,24 @@ const local: App.I18n.Schema = {
|
||||
404: '页面不存在',
|
||||
500: '服务器错误',
|
||||
'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_tab: '标签页',
|
||||
'function_multi-tab': '多标签页',
|
||||
@@ -491,6 +508,7 @@ const local: App.I18n.Schema = {
|
||||
orgType: {
|
||||
company: '公司',
|
||||
dept: '部门',
|
||||
function: '职能部门',
|
||||
direction: '方向',
|
||||
team: '团队'
|
||||
},
|
||||
|
||||
@@ -28,6 +28,17 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"function_super-page": () => import("@/views/function/super-page/index.vue"),
|
||||
function_tab: () => import("@/views/function/tab/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_charts_antv: () => import("@/views/plugin/charts/antv/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-management-relation": () => import("@/views/system/user-management-relation/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"),
|
||||
};
|
||||
|
||||
@@ -170,6 +170,43 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
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',
|
||||
path: '/login/:module(pwd-login|reset-pwd)?',
|
||||
@@ -182,6 +219,140 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
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',
|
||||
path: '/plugin',
|
||||
@@ -664,13 +835,53 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'user-center',
|
||||
path: '/user-center',
|
||||
component: 'layout.base$view.user-center',
|
||||
name: 'ticket',
|
||||
path: '/ticket',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'user-center',
|
||||
i18nKey: 'route.user-center',
|
||||
hideInMenu: true
|
||||
title: 'ticket',
|
||||
i18nKey: 'route.ticket',
|
||||
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
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -181,7 +181,21 @@ const routeMap: RouteMap = {
|
||||
"function_tab": "/function/tab",
|
||||
"function_toggle-auth": "/function/toggle-auth",
|
||||
"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)?",
|
||||
"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_barcode": "/plugin/barcode",
|
||||
"plugin_charts": "/plugin/charts",
|
||||
@@ -226,7 +240,10 @@ const routeMap: RouteMap = {
|
||||
"system_user": "/system/user",
|
||||
"system_user-detail": "/system/user-detail/:id",
|
||||
"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"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,16 @@ type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
|
||||
type ProjectExecutionPageResponse = Api.Project.PageResult<ProjectExecutionResponse>;
|
||||
type ProjectTaskPageResponse = Api.Project.PageResult<ProjectTaskResponse>;
|
||||
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'> & {
|
||||
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) {
|
||||
const result = await request<ProjectTaskResponse>({
|
||||
|
||||
8
src/service/api/workbench.ts
Normal file
8
src/service/api/workbench.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 工作台聚合接口尚未开通,当前页面使用 src/views/workbench/mock.ts 的本地假数据。
|
||||
// 接口契约确认后,在此处补:
|
||||
// - fetchGetWorkbenchSummary (Banner 摘要 + KPI)
|
||||
// - fetchGetWorkbenchTodos (我的待办)
|
||||
// - fetchGetWorkbenchActivity (最近动态)
|
||||
// - fetchGetWorkbenchProjects (我参与的项目)
|
||||
// 全部走 src/service/request/index.ts 的统一实例,并保持 ID 字符串口径。
|
||||
export {};
|
||||
@@ -242,7 +242,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
/** 统一处理常量路由和权限路由 */
|
||||
async function handleConstantAndAuthRoutes() {
|
||||
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);
|
||||
|
||||
|
||||
30
src/typings/api/project.d.ts
vendored
30
src/typings/api/project.d.ts
vendored
@@ -317,6 +317,36 @@ declare namespace Api {
|
||||
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 {
|
||||
parentTaskId: string | null;
|
||||
taskTitle: string;
|
||||
|
||||
2
src/typings/api/system-manage.d.ts
vendored
2
src/typings/api/system-manage.d.ts
vendored
@@ -69,7 +69,7 @@ declare namespace Api {
|
||||
roleCode: string;
|
||||
};
|
||||
|
||||
type DeptOrgType = 'company' | 'dept' | 'direction' | 'team';
|
||||
type DeptOrgType = 'company' | 'dept' | 'function' | 'direction' | 'team';
|
||||
|
||||
interface Dept {
|
||||
id: number;
|
||||
|
||||
3
src/typings/app.d.ts
vendored
3
src/typings/app.d.ts
vendored
@@ -333,7 +333,7 @@ declare namespace App {
|
||||
trigger: string;
|
||||
update: string;
|
||||
updateSuccess: string;
|
||||
userCenter: string;
|
||||
myProfile: string;
|
||||
yesOrNo: {
|
||||
yes: string;
|
||||
no: string;
|
||||
@@ -684,6 +684,7 @@ declare namespace App {
|
||||
orgType: {
|
||||
company: string;
|
||||
dept: string;
|
||||
function: string;
|
||||
direction: string;
|
||||
team: string;
|
||||
};
|
||||
|
||||
40
src/typings/elegant-router.d.ts
vendored
40
src/typings/elegant-router.d.ts
vendored
@@ -35,7 +35,21 @@ declare module "@elegant-router/types" {
|
||||
"function_tab": "/function/tab";
|
||||
"function_toggle-auth": "/function/toggle-auth";
|
||||
"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)?";
|
||||
"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_barcode": "/plugin/barcode";
|
||||
"plugin_charts": "/plugin/charts";
|
||||
@@ -80,7 +94,10 @@ declare module "@elegant-router/types" {
|
||||
"system_user": "/system/user";
|
||||
"system_user-detail": "/system/user-detail/:id";
|
||||
"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"
|
||||
| "function"
|
||||
| "iframe-page"
|
||||
| "infra"
|
||||
| "login"
|
||||
| "metrics"
|
||||
| "personal-center"
|
||||
| "plugin"
|
||||
| "product"
|
||||
| "project"
|
||||
| "system"
|
||||
| "user-center"
|
||||
| "ticket"
|
||||
| "workbench"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -157,6 +178,17 @@ declare module "@elegant-router/types" {
|
||||
| "function_super-page"
|
||||
| "function_tab"
|
||||
| "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_charts_antv"
|
||||
| "plugin_charts_echarts"
|
||||
@@ -192,7 +224,9 @@ declare module "@elegant-router/types" {
|
||||
| "system_user-detail"
|
||||
| "system_user-management-relation"
|
||||
| "system_user"
|
||||
| "user-center"
|
||||
| "ticket_my-pending"
|
||||
| "ticket_my-submitted"
|
||||
| "workbench"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
3
src/views/infra/rd-code/index.vue
Normal file
3
src/views/infra/rd-code/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="研发令号" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/infra/state-machine/index.vue
Normal file
3
src/views/infra/state-machine/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="状态机管理" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/metrics/member-efficiency/index.vue
Normal file
3
src/views/metrics/member-efficiency/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="员工能效" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/metrics/project-progress/index.vue
Normal file
3
src/views/metrics/project-progress/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="项目进度" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/metrics/worktime/index.vue
Normal file
3
src/views/metrics/worktime/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="工时统计" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/personal-center/my-application/index.vue
Normal file
3
src/views/personal-center/my-application/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="我的申请" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/personal-center/my-monthly/index.vue
Normal file
3
src/views/personal-center/my-monthly/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="我的月报" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/personal-center/my-performance/index.vue
Normal file
3
src/views/personal-center/my-performance/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="我的绩效" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/personal-center/my-profile/index.vue
Normal file
3
src/views/personal-center/my-profile/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="个人信息" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/personal-center/my-weekly/index.vue
Normal file
3
src/views/personal-center/my-weekly/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="我的周报" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/personal-center/pending-approval/index.vue
Normal file
3
src/views/personal-center/pending-approval/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="待我审批" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
767
src/views/product/dashboard/index.vue.bak
Normal file
767
src/views/product/dashboard/index.vue.bak
Normal 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { fetchGetProductActivityTimelinePage } from '@/service/api';
|
||||
import {
|
||||
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
|
||||
@@ -13,6 +13,7 @@ defineOptions({ name: 'ProductActivityTimelinePanel' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -27,6 +28,11 @@ const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
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() {
|
||||
if (!props.productId) {
|
||||
items.value = [];
|
||||
@@ -79,11 +85,17 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="product-activity-panel card-wrapper">
|
||||
<ElCard class="product-activity-panel card-wrapper" shadow="never">
|
||||
<template #header>
|
||||
<div class="product-activity-panel__header">
|
||||
<div>
|
||||
<h3 class="product-activity-panel__title">产品动态时间线</h3>
|
||||
<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>
|
||||
<p class="product-activity-panel__desc">对象、状态与团队的最近活动</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElButton text type="primary" :disabled="!productId" @click="openDialog">更多</ElButton>
|
||||
@@ -96,8 +108,8 @@ watch(
|
||||
<ElButton type="primary" plain @click="loadRecentActivities">重新加载</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length" class="product-activity-panel__timeline">
|
||||
<article v-for="item in items" :key="item.id" class="product-activity-panel__item">
|
||||
<div v-else-if="displayItems.length" class="product-activity-panel__timeline">
|
||||
<article v-for="item in displayItems" :key="item.id" class="product-activity-panel__item">
|
||||
<div class="product-activity-panel__rail">
|
||||
<span class="product-activity-panel__dot" :class="`product-activity-panel__dot--${item.tone}`" />
|
||||
<span class="product-activity-panel__line" />
|
||||
@@ -139,11 +151,36 @@ watch(
|
||||
|
||||
.product-activity-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 98%);
|
||||
@@ -152,19 +189,19 @@ watch(
|
||||
}
|
||||
|
||||
.product-activity-panel__desc {
|
||||
margin: 4px 0 0;
|
||||
margin: 3px 0 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-activity-panel__body {
|
||||
min-height: 520px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.product-activity-panel__state {
|
||||
display: flex;
|
||||
min-height: 420px;
|
||||
min-height: 200px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -273,10 +310,6 @@ watch(
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.product-activity-panel__body {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.product-activity-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
* - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决)
|
||||
* - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务
|
||||
* - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管)
|
||||
* - 执行负责人能维护一级任务协办人(一级任务 `executionOwnerId` 通道;子任务不开此通道)
|
||||
* - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态
|
||||
*
|
||||
* 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**,
|
||||
@@ -107,7 +108,11 @@ export function useTaskPermissions() {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Edit, Flag, User } from '@element-plus/icons-vue';
|
||||
import { formatDate, getProgressText, getTaskStatusName } from '../shared';
|
||||
import { useTaskPermissions } from '../composables/use-task-permissions';
|
||||
import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
|
||||
import { Calendar, Flag, User } from '@element-plus/icons-vue';
|
||||
import { formatDateRange, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
|
||||
import { useTaskActions } from '../composables/use-task-actions';
|
||||
import { type BoardBaseParams, useTaskBoardColumns } from '../composables/use-task-board-columns';
|
||||
|
||||
defineOptions({ name: 'ProjectExecutionTaskBoardView' });
|
||||
|
||||
interface Props {
|
||||
data: Api.Project.ProjectTask[];
|
||||
loading: boolean;
|
||||
projectId: string;
|
||||
executionId: string;
|
||||
statusBoard: Api.Project.StatusBoard | null;
|
||||
baseParams: BoardBaseParams;
|
||||
}
|
||||
|
||||
const { canEditTask } = useTaskPermissions();
|
||||
|
||||
interface Emits {
|
||||
(e: 'detail', 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',
|
||||
row: Api.Project.ProjectTask,
|
||||
@@ -27,101 +29,136 @@ interface Emits {
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const groupedTasks = computed(() => {
|
||||
const map = new Map<string, Api.Project.ProjectTask[]>();
|
||||
const items = props.statusBoard?.items ?? [];
|
||||
|
||||
items.forEach(item => {
|
||||
map.set(item.statusCode, []);
|
||||
});
|
||||
|
||||
props.data.forEach(task => {
|
||||
const list = map.get(task.statusCode);
|
||||
|
||||
if (list) {
|
||||
list.push(task);
|
||||
}
|
||||
});
|
||||
|
||||
return items.map(item => ({
|
||||
statusCode: item.statusCode,
|
||||
title: item.statusName,
|
||||
count: item.count,
|
||||
tasks: map.get(item.statusCode) || []
|
||||
}));
|
||||
const { columns, loadMore } = useTaskBoardColumns({
|
||||
projectId: toRef(props, 'projectId'),
|
||||
executionId: toRef(props, 'executionId'),
|
||||
// statusBoard 在此仅作"刷新事件源":workspace 任何变更后都会重新拉它,ref 引用变即触发本看板重拉首屏
|
||||
refreshSignal: toRef(props, 'statusBoard'),
|
||||
baseParams: () => props.baseParams
|
||||
});
|
||||
|
||||
function getFirstAction(row: Api.Project.ProjectTask) {
|
||||
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染按钮
|
||||
return row.availableActions.find(item => item.actionCode !== 'auto_start') || null;
|
||||
// 看板卡片操作按钮(与表格操作列同语义)。
|
||||
// 兼容 useTaskActions 的"叶子判定"需求:拍平当前已加载的全部任务做集合。
|
||||
const allLoadedTasks = computed(() => columns.value.flatMap(item => item.tasks));
|
||||
|
||||
const { createActions } = useTaskActions(allLoadedTasks, {
|
||||
edit: row => emit('edit', row),
|
||||
report: row => emit('report', row),
|
||||
remove: row => emit('delete', row),
|
||||
statusAction: (row, action) => emit('status-action', row, action)
|
||||
});
|
||||
|
||||
// 每个列对应一个底部 sentinel,IntersectionObserver 观测到入视即触发 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 === null:sentinel 卸载(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);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: '200px 0px' }
|
||||
);
|
||||
sentinelRegistry.forEach((_, el) => observer!.observe(el));
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
sentinelRegistry.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="task-board-card" body-class="task-board-card__body">
|
||||
<ElSkeleton v-if="loading" :rows="6" animated />
|
||||
<div v-else class="task-board">
|
||||
<section v-for="column in groupedTasks" :key="column.statusCode" class="task-board-column">
|
||||
<div class="task-board">
|
||||
<section v-for="column in columns" :key="column.statusCode" class="task-board-column">
|
||||
<header class="task-board-column__header">
|
||||
<strong>{{ column.title }}</strong>
|
||||
<ElTag effect="plain" size="small">{{ column.count }}</ElTag>
|
||||
<ElTag effect="plain" size="small" round>{{ column.total }}</ElTag>
|
||||
</header>
|
||||
|
||||
<ElScrollbar class="task-board-column__scrollbar">
|
||||
<ElEmpty v-if="column.tasks.length === 0" :description="`暂无${column.title}任务`" />
|
||||
<template v-else>
|
||||
<article
|
||||
v-for="task in column.tasks"
|
||||
:key="task.id"
|
||||
class="task-board-card-item"
|
||||
@click="emit('detail', task)"
|
||||
>
|
||||
<div class="task-board-card-item__top">
|
||||
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
|
||||
<ElTag effect="plain" size="small">{{ getTaskStatusName(task) }}</ElTag>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-if="column.tasks.length === 0 && !column.loading"
|
||||
:description="`暂无${column.title}任务`"
|
||||
:image-size="64"
|
||||
/>
|
||||
<article
|
||||
v-for="task in column.tasks"
|
||||
:key="task.id"
|
||||
class="task-board-card-item"
|
||||
@click="emit('detail', task)"
|
||||
>
|
||||
<div class="task-board-card-item__top">
|
||||
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
|
||||
<ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small">
|
||||
{{ getTaskStatusName(task) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<div class="task-board-card-item__meta">
|
||||
<span>
|
||||
<ElIcon><User /></ElIcon>
|
||||
{{ task.ownerNickname || task.ownerId || '未设置负责人' }}
|
||||
</span>
|
||||
<span>
|
||||
<ElIcon><Flag /></ElIcon>
|
||||
计划结束 {{ formatDate(task.plannedEndDate) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-board-card-item__meta">
|
||||
<span>
|
||||
<ElIcon><User /></ElIcon>
|
||||
{{ task.ownerNickname || task.ownerId || '未设置负责人' }}
|
||||
</span>
|
||||
<span>
|
||||
<ElIcon><Flag /></ElIcon>
|
||||
计划 {{ formatDateRange(task.plannedStartDate, task.plannedEndDate) }}
|
||||
</span>
|
||||
<span>
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
实际 {{ formatDateRange(task.actualStartDate, task.actualEndDate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="task-board-card-item__progress">
|
||||
<span>进度 {{ getProgressText(task.progressRate) }}</span>
|
||||
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
|
||||
</div>
|
||||
<div class="task-board-card-item__progress">
|
||||
<span>进度 {{ getProgressText(task.progressRate) }}</span>
|
||||
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
|
||||
</div>
|
||||
|
||||
<div class="task-board-card-item__actions" @click.stop>
|
||||
<ElButton v-if="canEditTask(task)" size="small" plain :icon="Edit" @click="emit('edit', task)">
|
||||
编辑
|
||||
<div v-if="createActions(task).length" class="task-board-card-item__actions" @click.stop>
|
||||
<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()">
|
||||
<component :is="action.icon" class="text-15px" />
|
||||
</ElButton>
|
||||
<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>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
@@ -235,12 +272,36 @@ function getFirstAction(row: Api.Project.ProjectTask) {
|
||||
|
||||
.task-board-card-item__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
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) {
|
||||
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>
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw } from 'vue';
|
||||
import { computed, toRef } from 'vue';
|
||||
import type { PaginationProps } from 'element-plus';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import {
|
||||
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';
|
||||
import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
|
||||
import { useTaskActions } from '../composables/use-task-actions';
|
||||
|
||||
defineOptions({ name: 'ProjectExecutionTaskTableView' });
|
||||
|
||||
@@ -42,10 +27,12 @@ interface Emits {
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
|
||||
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions();
|
||||
const { createActions } = useTaskActions(toRef(props, 'data'), {
|
||||
edit: row => emit('edit', row),
|
||||
report: row => emit('report', row),
|
||||
remove: row => emit('delete', row),
|
||||
statusAction: (row, action) => emit('status-action', row, action)
|
||||
});
|
||||
|
||||
const paginationVisible = computed(() => Boolean(props.pagination.total));
|
||||
|
||||
@@ -66,80 +53,6 @@ function getParentTaskLabel(parentTaskId: string | null) {
|
||||
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) {
|
||||
props.pagination['current-change']?.(page);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
|
||||
import type { WorklogChangedPayload } from '../shared';
|
||||
import TaskWorklogPanel from './task-worklog-panel.vue';
|
||||
@@ -24,8 +25,26 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
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_start)OR active OR completed(completed 后填报不回写进度,由 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 recordsLoading = ref(false);
|
||||
@@ -212,10 +231,11 @@ watch(
|
||||
:execution-id="task.executionId"
|
||||
:task-id="task.id"
|
||||
:task-owner-id="task.ownerId"
|
||||
:task-status-code="task.statusCode"
|
||||
:owner-nickname="task.ownerNickname"
|
||||
:assignees="task.assignees"
|
||||
:task-progress-rate="task.progressRate"
|
||||
:can-submit="true"
|
||||
:can-submit="canSubmitWorklog"
|
||||
:external-list="records"
|
||||
:show-assignee-column="isOwner"
|
||||
@changed="handleWorklogChanged"
|
||||
|
||||
@@ -19,6 +19,8 @@ interface Props {
|
||||
executionId: string;
|
||||
taskId: string;
|
||||
taskOwnerId: string | null;
|
||||
/** 任务状态码;completed 时进度字段只读(后端不回写 task.progressRate,避免误改) */
|
||||
taskStatusCode: string;
|
||||
/** 创建模式下的进度兜底默认值(owner 路径会传 task.progressRate) */
|
||||
defaultOwnerProgressRate?: number;
|
||||
/** 提交中(HTTP 进行中),由父组件控制 */
|
||||
@@ -43,6 +45,8 @@ const authStore = useAuthStore();
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
const isOwner = computed(() => Boolean(props.taskOwnerId && props.taskOwnerId === currentUserId.value));
|
||||
const isView = computed(() => props.mode === 'view');
|
||||
// 任务 completed 后填工时不回写进度(§4.8.4 备注),此时进度字段只读,避免误导用户以为能改任务进度
|
||||
const isProgressReadonly = computed(() => isView.value || props.taskStatusCode === 'completed');
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
@@ -399,7 +403,7 @@ defineExpose({
|
||||
:max="100"
|
||||
:step="1"
|
||||
:precision="2"
|
||||
:disabled="isView"
|
||||
:disabled="isProgressReadonly"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface Props {
|
||||
executionId: string;
|
||||
taskId: string;
|
||||
taskOwnerId: string | null;
|
||||
/** 任务状态码;用于编辑/删除按钮的状态前置(active / completed 才允许,与 §4.8.4 矩阵对齐) */
|
||||
taskStatusCode: string;
|
||||
/** 当前用户是否被允许填报(owner 或活跃协办人) */
|
||||
canSubmit: boolean;
|
||||
/** 用于 form-dialog 在 owner 路径下显示默认进度(task.progressRate) */
|
||||
@@ -182,13 +184,18 @@ function getRowIndex(index: number) {
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return Boolean(currentUserId.value && row.userId === currentUserId.value);
|
||||
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
|
||||
}
|
||||
|
||||
function formatHours(hours: number | null | undefined) {
|
||||
@@ -591,6 +598,7 @@ watch(
|
||||
:execution-id="executionId"
|
||||
:task-id="taskId"
|
||||
:task-owner-id="taskOwnerId"
|
||||
:task-status-code="taskStatusCode"
|
||||
:default-owner-progress-rate="taskProgressRate"
|
||||
:confirm-loading="submitting"
|
||||
@submit="handleSubmit"
|
||||
|
||||
@@ -192,13 +192,23 @@ function resetSearchParams() {
|
||||
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() {
|
||||
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
|
||||
await Promise.all([refreshTableData(true), loadTaskStatusBoard()]);
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
resetSearchParams();
|
||||
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
|
||||
await Promise.all([refreshTableData(true), loadTaskStatusBoard()]);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
@@ -294,7 +304,7 @@ async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
|
||||
// 业务保存成功才 commit:删除用户在弹层里标记删除的附件
|
||||
await taskOperateDialogRef.value?.commit();
|
||||
operateVisible.value = false;
|
||||
await getData();
|
||||
await refreshTableData();
|
||||
}
|
||||
|
||||
async function handleStatusSubmit(reason: string | null) {
|
||||
@@ -321,7 +331,7 @@ async function handleStatusSubmit(reason: string | null) {
|
||||
const completedTask = currentTask.value;
|
||||
pendingCascade.value = false;
|
||||
|
||||
await Promise.all([getData(), loadTaskStatusBoard()]);
|
||||
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
|
||||
emit('executionChanged');
|
||||
|
||||
if (wasCascade && completedTask) {
|
||||
@@ -345,7 +355,7 @@ async function handleReport(row: Api.Project.ProjectTask) {
|
||||
}
|
||||
|
||||
async function handleWorklogChanged(payload: WorklogChangedPayload) {
|
||||
await Promise.all([getData(), loadTaskStatusBoard()]);
|
||||
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
|
||||
|
||||
// 工时变化可能触发后端联动改任务状态、实际开始、进度等;
|
||||
// 同步刷新当前打开弹层的 task 快照,避免用户必须关弹层重开才能看到新值
|
||||
@@ -442,7 +452,7 @@ async function refreshAssigneesAfterMutation() {
|
||||
assigneesLoading.value = false;
|
||||
currentAssignees.value = error || !assigneeData ? [] : assigneeData;
|
||||
|
||||
await getData();
|
||||
await refreshTableData();
|
||||
}
|
||||
|
||||
async function loadExecutionAssigneeOptions() {
|
||||
@@ -483,7 +493,7 @@ async function confirmDeleteTask(payload: { name: string; confirmText: string; r
|
||||
window.$message?.success('删除成功');
|
||||
deleteTaskDialogVisible.value = false;
|
||||
deleteTaskTarget.value = null;
|
||||
await Promise.all([getData(), loadTaskStatusBoard()]);
|
||||
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
|
||||
}
|
||||
|
||||
async function loadTaskStatusBoard() {
|
||||
@@ -517,6 +527,13 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 看板期间的搜索/变更不会触发表格分页;切回表格时按当前过滤补拉一次首页,避免表格数据陈旧
|
||||
watch(viewMode, async mode => {
|
||||
if (mode === 'table' && canLoadTasks.value) {
|
||||
await getDataByPage(1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -562,24 +579,17 @@ watch(
|
||||
/>
|
||||
<TaskBoardView
|
||||
v-else
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:project-id="projectId"
|
||||
:execution-id="executionId"
|
||||
:status-board="taskStatusBoard"
|
||||
:base-params="createStatusBoardParams()"
|
||||
@detail="handleDetail"
|
||||
@edit="handleEdit"
|
||||
@report="handleReport"
|
||||
@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
|
||||
ref="taskOperateDialogRef"
|
||||
v-model:visible="operateVisible"
|
||||
@@ -694,11 +704,6 @@ watch(
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.task-workspace__board-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.task-workspace__empty {
|
||||
flex: 1;
|
||||
border: 1px dashed rgb(203 213 225 / 92%);
|
||||
|
||||
@@ -215,11 +215,12 @@ export function isTaskLeafInList(row: Api.Project.ProjectTask, allRows: Api.Proj
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否对该任务行展示「填报」入口(与后端 2026-05-11 工时填报矩阵对齐):
|
||||
* - 已登录、当前页叶子、且当前用户是 owner / 活跃协作人为基础门槛
|
||||
* - 待开始 / 进行中:负责人、协办人均可
|
||||
* 是否对该任务行展示「填报」入口(与后端工时填报矩阵 §4.8.4 对齐 + 业务事实修正):
|
||||
* - 已登录、当前页叶子、且当前用户是 owner / 活跃协办人为基础门槛
|
||||
* - 待开始(pending):负责人、协办人均可——首次填工时是 auto_start 触发口(无独立"开始"按钮)
|
||||
* - 进行中(active):负责人、协办人均可
|
||||
* - 已完成(completed):负责人、协办人均可(补录 / 修正用于报表补齐;后端不回写 task.progressRate)
|
||||
* - 已暂停 / 已取消:双方均拒
|
||||
* - 已完成:仅协办人可补登历史工时;负责人拦截(避免负责人填工时把进度改回低值)
|
||||
*/
|
||||
export function canReportTaskWorklog(
|
||||
row: Api.Project.ProjectTask,
|
||||
@@ -239,17 +240,7 @@ export function canReportTaskWorklog(
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (row.statusCode) {
|
||||
case 'pending':
|
||||
case 'active':
|
||||
return true;
|
||||
case 'completed':
|
||||
return !isOwner && isActiveAssignee;
|
||||
case 'paused':
|
||||
case 'cancelled':
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return row.statusCode === 'pending' || row.statusCode === 'active' || row.statusCode === 'completed';
|
||||
}
|
||||
|
||||
type TaskAssigneeActionType = Api.Project.TaskAssigneeActionType;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
785
src/views/project/project/overview/index.vue.bak
Normal file
785
src/views/project/project/overview/index.vue.bak
Normal 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>
|
||||
@@ -37,7 +37,7 @@ defineOptions({ name: 'UserManagementRelation' });
|
||||
*
|
||||
* @param fromUserIndex 是否不是从管理链路 index 页面访问(从 user 页面访问时为 true)
|
||||
* @param deptId 部门 ID
|
||||
* @param orgType 组织类型(company/dept/direction/team)
|
||||
* @param orgType 组织类型(company/dept/function/direction/team)
|
||||
*/
|
||||
interface userQuery {
|
||||
fromUserIndex?: boolean;
|
||||
|
||||
@@ -46,6 +46,7 @@ const title = computed(() => {
|
||||
const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [
|
||||
{ value: 'company', label: 'page.system.user.orgType.company' },
|
||||
{ value: 'dept', label: 'page.system.user.orgType.dept' },
|
||||
{ value: 'function', label: 'page.system.user.orgType.function' },
|
||||
{ value: 'direction', label: 'page.system.user.orgType.direction' },
|
||||
{ value: 'team', label: 'page.system.user.orgType.team' }
|
||||
];
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { markRaw, ref, watch } from 'vue';
|
||||
import type { TreeInstance } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
import IconMdiAccountGroup from '~icons/mdi/account-group';
|
||||
import IconMdiDomain from '~icons/mdi/domain';
|
||||
import IconMdiOfficeBuilding from '~icons/mdi/office-building';
|
||||
import IconMdiSourceBranch from '~icons/mdi/source-branch';
|
||||
import IconMdiAccountGroupOutline from '~icons/mdi/account-group-outline';
|
||||
import IconMdiArrowDecisionOutline from '~icons/mdi/arrow-decision-outline';
|
||||
import IconMdiBriefcaseVariantOutline from '~icons/mdi/briefcase-variant-outline';
|
||||
import IconMdiOfficeBuildingOutline from '~icons/mdi/office-building-outline';
|
||||
import IconMdiSitemapOutline from '~icons/mdi/sitemap-outline';
|
||||
|
||||
defineOptions({ name: 'UserOrgPanel' });
|
||||
|
||||
@@ -43,10 +44,11 @@ function filterNode(value: string, nodeData: Api.SystemManage.Dept) {
|
||||
|
||||
function getOrgIcon(orgType: Api.SystemManage.DeptOrgType) {
|
||||
const iconMap: Record<Api.SystemManage.DeptOrgType, object> = {
|
||||
company: markRaw(IconMdiDomain),
|
||||
dept: markRaw(IconMdiOfficeBuilding),
|
||||
direction: markRaw(IconMdiSourceBranch),
|
||||
team: markRaw(IconMdiAccountGroup)
|
||||
company: markRaw(IconMdiOfficeBuildingOutline),
|
||||
dept: markRaw(IconMdiSitemapOutline),
|
||||
function: markRaw(IconMdiBriefcaseVariantOutline),
|
||||
direction: markRaw(IconMdiArrowDecisionOutline),
|
||||
team: markRaw(IconMdiAccountGroupOutline)
|
||||
};
|
||||
|
||||
return iconMap[orgType];
|
||||
|
||||
3
src/views/ticket/my-pending/index.vue
Normal file
3
src/views/ticket/my-pending/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="待我处理的工单" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
3
src/views/ticket/my-submitted/index.vue
Normal file
3
src/views/ticket/my-submitted/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<LookForward title="我提交的工单" subtitle="功能建设中,敬请期待" />
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
347
src/views/workbench/homepage.ts
Normal file
347
src/views/workbench/homepage.ts
Normal 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()]}`;
|
||||
}
|
||||
65
src/views/workbench/index.vue
Normal file
65
src/views/workbench/index.vue
Normal 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
194
src/views/workbench/mock.ts
Normal 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[];
|
||||
169
src/views/workbench/modules/workbench-activity-panel.vue
Normal file
169
src/views/workbench/modules/workbench-activity-panel.vue
Normal 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>
|
||||
318
src/views/workbench/modules/workbench-banner.vue
Normal file
318
src/views/workbench/modules/workbench-banner.vue
Normal 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>
|
||||
192
src/views/workbench/modules/workbench-kpi.vue
Normal file
192
src/views/workbench/modules/workbench-kpi.vue
Normal 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>
|
||||
311
src/views/workbench/modules/workbench-project-grid.vue
Normal file
311
src/views/workbench/modules/workbench-project-grid.vue
Normal 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>
|
||||
347
src/views/workbench/modules/workbench-todo-panel.vue
Normal file
347
src/views/workbench/modules/workbench-todo-panel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user