Commit 12c2303f authored by David Yang's avatar David Yang

feat: 应用切换

parent ca0cbf30
File added
...@@ -14,6 +14,8 @@ const ROUTE_PATHS = { ...@@ -14,6 +14,8 @@ const ROUTE_PATHS = {
export default defineConfig({ export default defineConfig({
layout: false, layout: false,
fastRefresh: true, fastRefresh: true,
base: '/project_manager_xy/',
publicPath: '/project_manager_xy/',
alias: { alias: {
react: resolvePackageRoot('react'), react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'), 'react-dom': resolvePackageRoot('react-dom'),
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"": { "": {
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@umijs/max": "^4.5.3", "@umijs/max": "^4.5.3",
...@@ -2441,6 +2442,21 @@ ...@@ -2441,6 +2442,21 @@
"@floating-ui/core": "^0.6.2" "@floating-ui/core": "^0.6.2"
} }
}, },
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": { "node_modules/@floating-ui/react-dom": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.6.3.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.6.3.tgz",
...@@ -2468,6 +2484,44 @@ ...@@ -2468,6 +2484,44 @@
"use-isomorphic-layout-effect": "^1.1.1" "use-isomorphic-layout-effect": "^1.1.1"
} }
}, },
"node_modules/@floating-ui/react/node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react/node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react/node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@formatjs/intl-displaynames": { "node_modules/@formatjs/intl-displaynames": {
"version": "1.2.10", "version": "1.2.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-1.2.10.tgz", "resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-1.2.10.tgz",
...@@ -2512,6 +2566,35 @@ ...@@ -2512,6 +2566,35 @@
"deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package", "deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@headlessui/react": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz",
"integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.20.2",
"@react-aria/interactions": "^3.25.0",
"@tanstack/react-virtual": "^3.13.9",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@headlessui/react/node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@heroicons/react": { "node_modules/@heroicons/react": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
...@@ -2604,6 +2687,33 @@ ...@@ -2604,6 +2687,33 @@
"local-pkg": "^0.4.2" "local-pkg": "^0.4.2"
} }
}, },
"node_modules/@internationalized/date": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz",
"integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/string": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.9.tgz",
"integrity": "sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
...@@ -3458,6 +3568,44 @@ ...@@ -3458,6 +3568,44 @@
"react-dom": ">=16.9.0" "react-dom": ">=16.9.0"
} }
}, },
"node_modules/@react-aria/focus": {
"version": "3.22.1",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.1.tgz",
"integrity": "sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0",
"react-aria": "^3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.1.tgz",
"integrity": "sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"react-aria": "^3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.27.10", "version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
...@@ -4034,6 +4182,33 @@ ...@@ -4034,6 +4182,33 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
"integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
...@@ -17111,6 +17286,36 @@ ...@@ -17111,6 +17286,36 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-aria": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.47.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-aria/node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
...@@ -17267,6 +17472,32 @@ ...@@ -17267,6 +17472,32 @@
"react": ">=15" "react": ">=15"
} }
}, },
"node_modules/react-stately": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-stately/node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/reactcss": { "node_modules/reactcss": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
...@@ -19246,6 +19477,12 @@ ...@@ -19246,6 +19477,12 @@
"integrity": "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==", "integrity": "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/table": { "node_modules/table": {
"version": "6.9.0", "version": "6.9.0",
"resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz",
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"start": "npm run dev" "start": "npm run dev"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@umijs/max": "^4.5.3", "@umijs/max": "^4.5.3",
......
{
"name": "project_manager_xy",
"version": "1.0.3",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
File added
import { getApplications } from '@/services/DataService';
import * as Headless from '@headlessui/react';
import {
ArrowTopRightOnSquareIcon,
ExclamationCircleIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
return {
name,
title:
application?.title ||
fallback?.title ||
application?.name ||
fallback?.name ||
'未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
};
}
function formatAccessLabel(access) {
if (access === 'public') {
return '公开';
}
if (access === 'protected') {
return '受限';
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(
application.name,
normalizeApplication(application, application),
);
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
function ApplicationCard({ application }) {
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
return (
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
className={clsx(
'group relative min-h-[84px] overflow-hidden rounded-2xl border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.06)] transition duration-200 ease-out hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-[0_16px_32px_rgba(244,63,94,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
current &&
'border-rose-300 bg-[linear-gradient(180deg,rgba(255,241,242,0.95),rgba(255,255,255,0.96))]',
)}
>
<div className="absolute inset-x-0 top-0 h-14 bg-[radial-gradient(circle_at_top_right,rgba(251,113,133,0.14),transparent_50%)] opacity-0 transition duration-200 group-hover:opacity-100" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
{application.logo ? (
<img
src={application.logo}
alt={application.title}
className="size-9 rounded-xl border border-white/80 bg-white object-cover shadow-sm"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,var(--pm-brand),#f43f5e)] text-sm font-semibold text-white shadow-[0_8px_18px_rgba(180,35,24,0.22)]">
{application.title.slice(0, 1)}
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-950">
{application.title}
</div>
<div className="mt-0.5 truncate text-[11px] leading-4 text-slate-500">
{application.description}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{current ? (
<span className="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] font-medium text-rose-700">
当前
</span>
) : null}
<span className="rounded-full bg-slate-100 p-1.5 text-slate-500 transition group-hover:bg-rose-50 group-hover:text-rose-600">
<ArrowTopRightOnSquareIcon className="size-3.5" />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className="relative mt-2 flex flex-wrap gap-1.5">
{accessLabel ? (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{accessLabel}
</span>
) : null}
{firstRole ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">
{firstRole}
</span>
) : null}
{firstGroup ? (
<span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700">
{firstGroup}
</span>
) : null}
</div>
) : null}
</a>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[84px] rounded-2xl border border-zinc-200 bg-white/90 p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.05)]"
>
<div className="animate-pulse">
<div className="flex items-start gap-3">
<div className="size-9 rounded-xl bg-zinc-200" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-zinc-200" />
<div className="h-3 w-full rounded-full bg-zinc-100" />
</div>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-12 rounded-full bg-zinc-100" />
<div className="h-5 w-14 rounded-full bg-zinc-100" />
</div>
</div>
</div>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
siteInfo,
}) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, siteInfo?.group);
}, [applications, siteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Headless.Dialog
open={open}
onClose={onClose}
className="relative z-[180]"
>
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-[rgba(15,23,42,0.48)] backdrop-blur-sm transition duration-300 ease-out data-closed:opacity-0"
/>
<div className="fixed inset-0 overflow-y-auto p-3 sm:p-4 lg:p-5">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
transition
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-[92rem] flex-col overflow-hidden rounded-[28px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(248,250,252,0.94))] p-4 shadow-[0_30px_80px_rgba(15,23,42,0.22)] ring-1 ring-black/5 transition duration-300 ease-out data-closed:translate-y-4 data-closed:scale-[0.97] data-closed:opacity-0 sm:p-5"
>
<div className="flex shrink-0 flex-col gap-3 border-b border-zinc-200/80 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-600">
<SparklesIcon className="size-3.5" />
Applications
</div>
<Headless.DialogTitle className="mt-2 text-xl font-semibold tracking-tight text-slate-950 sm:text-2xl">
应用切换
</Headless.DialogTitle>
<p className="mt-1 max-w-2xl text-xs leading-5 text-slate-500">
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-rose-100 bg-rose-50/70 px-3 py-2 text-right">
<div className="text-[11px] font-medium tracking-wide text-rose-500">
可见应用
</div>
<div className="mt-0.5 text-xl font-semibold tracking-tight text-slate-950">
{applications.length}
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 items-center justify-center rounded-xl border border-zinc-200 bg-white px-3.5 text-sm font-medium text-slate-600 transition hover:border-zinc-300 hover:text-slate-900"
>
关闭
</button>
</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-gutter:stable]">
{status === 'error' && errorMessage ? (
<div className="mb-4 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<ExclamationCircleIcon className="mt-0.5 size-5 shrink-0 text-amber-500" />
<div>{errorMessage}</div>
</div>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="space-y-4">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-2 flex items-center gap-3">
<h3 className="text-sm font-semibold text-slate-900">
{section.label}
</h3>
<span className="h-px flex-1 bg-zinc-200/80" />
<span className="text-xs font-medium text-slate-400">
{section.applications.length}
</span>
</div>
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-[28px] border border-dashed border-zinc-200 bg-zinc-50/80 px-6 text-center">
<div className="text-lg font-semibold text-slate-900">
暂无可切换应用
</div>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</p>
</div>
)}
</div>
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
);
}
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import xyLogo from '@/assets/xy-logo.png'; import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { import {
Bars3Icon,
CalendarDaysIcon, CalendarDaysIcon,
ChartBarSquareIcon, ChartBarSquareIcon,
Bars3Icon, Squares2X2Icon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
...@@ -174,10 +177,12 @@ function MobileSidebar({ open, close, children }) { ...@@ -174,10 +177,12 @@ function MobileSidebar({ open, close, children }) {
export default function ProjectManagerLayout() { export default function ProjectManagerLayout() {
const location = useLocation(); const location = useLocation();
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const userInfo = useContext(UserContext);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
...@@ -219,6 +224,23 @@ export default function ProjectManagerLayout() { ...@@ -219,6 +224,23 @@ export default function ProjectManagerLayout() {
); );
}, [location.pathname]); }, [location.pathname]);
const applicationSwitcherButton = (
<button
type="button"
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
title="应用切换"
className={clsx(
'inline-flex items-center justify-center rounded-[20px] border border-[#f97316]/70 bg-[linear-gradient(180deg,rgba(39,39,42,0.92),rgba(24,24,27,0.96))] text-[#fb923c] shadow-[0_16px_30px_rgba(249,115,22,0.16)] transition duration-200 hover:-translate-y-0.5 hover:border-[#fb923c] hover:text-[#fdba74] hover:shadow-[0_18px_36px_rgba(249,115,22,0.26)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/50',
sidebarCollapsed ? 'size-11' : 'size-14',
)}
>
<Squares2X2Icon
className={clsx(sidebarCollapsed ? 'size-5' : 'size-7')}
/>
</button>
);
return ( return (
<div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]"> <div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]">
<aside <aside
...@@ -229,25 +251,34 @@ export default function ProjectManagerLayout() { ...@@ -229,25 +251,34 @@ export default function ProjectManagerLayout() {
> >
<div <div
className={clsx( className={clsx(
'mb-7 flex items-center', 'mb-7',
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1', sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)} )}
> >
{sidebarCollapsed ? (
<>
<img <img
src={sidebarCollapsed ? xyLogoSmall : xyLogo} src={xyLogoSmall}
alt="XY Logo" alt="XY Logo"
className={clsx( className="h-8 w-auto max-w-8 shrink-0 object-contain"
'shrink-0 object-contain',
sidebarCollapsed
? 'h-8 w-auto max-w-8'
: 'h-8 w-auto max-w-[140px]',
)}
/> />
{!sidebarCollapsed ? ( {applicationSwitcherButton}
</>
) : (
<>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> <h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME} {APP_NAME}
</h1> </h1>
) : null} <div className="mt-1 text-xs text-zinc-400">
项目资源管理工作台
</div>
</div>
{applicationSwitcherButton}
</>
)}
</div> </div>
<nav className="flex-1 space-y-5 overflow-y-auto"> <nav className="flex-1 space-y-5 overflow-y-auto">
...@@ -344,12 +375,26 @@ export default function ProjectManagerLayout() { ...@@ -344,12 +375,26 @@ export default function ProjectManagerLayout() {
<Bars3Icon className="size-5" /> <Bars3Icon className="size-5" />
</NavbarItem> </NavbarItem>
<div className="text-sm text-zinc-500">{currentGroupTitle}</div> <div className="text-sm text-zinc-500">{currentGroupTitle}</div>
<NavbarItem
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
className="border border-rose-200 bg-white text-rose-500 shadow-sm hover:bg-rose-50 hover:text-rose-600"
>
<Squares2X2Icon className="size-5" />
</NavbarItem>
</div> </div>
<div className="flex-1 overflow-y-auto rounded-xl border border-zinc-200 bg-white/95 p-4 shadow-[0_20px_50px_rgba(15,23,42,0.06)] sm:p-6"> <div className="flex-1 overflow-y-auto rounded-xl border border-zinc-200 bg-white/95 p-4 shadow-[0_20px_50px_rgba(15,23,42,0.06)] sm:p-6">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div> </div>
); );
} }
...@@ -23,6 +23,7 @@ import { ...@@ -23,6 +23,7 @@ import {
saveProjectTask, saveProjectTask,
searchSystemUsers, searchSystemUsers,
sortWeeklyUpdates, sortWeeklyUpdates,
deleteProjectTask,
} from './projectTaskService'; } from './projectTaskService';
function resolveCurrentDate() { function resolveCurrentDate() {
...@@ -1920,9 +1921,11 @@ function ProjectTaskDialog({ ...@@ -1920,9 +1921,11 @@ function ProjectTaskDialog({
open, open,
task, task,
submitting, submitting,
deleting,
submitError, submitError,
onClose, onClose,
onSubmit, onSubmit,
onDelete,
fieldOptions, fieldOptions,
}) { }) {
const [formValues, setFormValues] = useState(buildTaskFormValues(null)); const [formValues, setFormValues] = useState(buildTaskFormValues(null));
...@@ -1932,6 +1935,7 @@ function ProjectTaskDialog({ ...@@ -1932,6 +1935,7 @@ function ProjectTaskDialog({
buildWeeklyUpdateFormValues(null), buildWeeklyUpdateFormValues(null),
); );
const [weeklyUpdateError, setWeeklyUpdateError] = useState(''); const [weeklyUpdateError, setWeeklyUpdateError] = useState('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
...@@ -1942,6 +1946,7 @@ function ProjectTaskDialog({ ...@@ -1942,6 +1946,7 @@ function ProjectTaskDialog({
setEditingWeeklyUpdateIndex(-1); setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null)); setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
setWeeklyUpdateError(''); setWeeklyUpdateError('');
setDeleteConfirmOpen(false);
}, [open, task]); }, [open, task]);
if (!open) { if (!open) {
...@@ -2086,6 +2091,7 @@ function ProjectTaskDialog({ ...@@ -2086,6 +2091,7 @@ function ProjectTaskDialog({
? fieldOptions.occurrenceFrequencyOptions ? fieldOptions.occurrenceFrequencyOptions
: OCCURRENCE_FREQUENCY_OPTIONS; : OCCURRENCE_FREQUENCY_OPTIONS;
const hasPersistedTask = Boolean(getTaskRecordId(task)); const hasPersistedTask = Boolean(getTaskRecordId(task));
const isBusy = submitting || deleting;
return ( return (
<> <>
...@@ -2102,7 +2108,7 @@ function ProjectTaskDialog({ ...@@ -2102,7 +2108,7 @@ function ProjectTaskDialog({
<button <button
type="button" type="button"
onClick={handleStartProject} onClick={handleStartProject}
disabled={submitting} disabled={isBusy}
className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100" className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100"
> >
{submitting ? '启动中...' : '启动项目'} {submitting ? '启动中...' : '启动项目'}
...@@ -2112,7 +2118,7 @@ function ProjectTaskDialog({ ...@@ -2112,7 +2118,7 @@ function ProjectTaskDialog({
<button <button
type="button" type="button"
onClick={handleCompleteProject} onClick={handleCompleteProject}
disabled={submitting} disabled={isBusy}
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-100" className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-100"
> >
{submitting ? '完成中...' : '完成项目'} {submitting ? '完成中...' : '完成项目'}
...@@ -2121,7 +2127,7 @@ function ProjectTaskDialog({ ...@@ -2121,7 +2127,7 @@ function ProjectTaskDialog({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={submitting} disabled={isBusy}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100" className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
> >
关闭 关闭
...@@ -2437,23 +2443,66 @@ function ProjectTaskDialog({ ...@@ -2437,23 +2443,66 @@ function ProjectTaskDialog({
</div> </div>
) : null} ) : null}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-zinc-100 pt-5"> <div className="mt-6 flex flex-wrap items-end justify-between gap-4 border-t border-zinc-100 pt-5">
<div className="flex-1">
<div className="text-sm text-zinc-500"> <div className="text-sm text-zinc-500">
当前状态:{PROJECT_STATUS_LABEL_MAP[formValues.project_status] || '-'} 当前状态:{PROJECT_STATUS_LABEL_MAP[formValues.project_status] || '-'}
{' · '} {' · '}
{PRIORITY_LABEL_MAP[formValues.priority] || '未设置优先级'} {PRIORITY_LABEL_MAP[formValues.priority] || '未设置优先级'}
</div> </div>
{hasPersistedTask ? (
deleteConfirmOpen ? (
<div className="mt-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3">
<div className="text-sm font-medium text-rose-700">
确认删除当前任务?
</div>
<div className="mt-1 text-xs text-rose-600">
删除后项目会从甘特图、日历图和项目清单中移除。
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setDeleteConfirmOpen(false)}
disabled={isBusy}
className="rounded-xl border border-rose-200 bg-white px-4 py-2 text-sm font-medium text-rose-600 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-70"
>
取消删除
</button>
<button
type="button"
onClick={() => onDelete?.(task)}
disabled={isBusy}
className="rounded-xl bg-rose-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-70"
>
{deleting ? '删除中...' : '确认删除'}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirmOpen(true)}
disabled={isBusy}
className="mt-3 rounded-xl border border-rose-200 bg-white px-4 py-2 text-sm font-medium text-rose-600 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-70"
>
删除当前任务
</button>
)
) : null}
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={isBusy}
className="rounded-xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-600 transition hover:bg-zinc-100" className="rounded-xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-600 transition hover:bg-zinc-100"
> >
取消 取消
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitting} disabled={isBusy}
className="rounded-xl bg-[var(--pm-brand)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--pm-brand-strong)] disabled:cursor-not-allowed disabled:opacity-70" className="rounded-xl bg-[var(--pm-brand)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--pm-brand-strong)] disabled:cursor-not-allowed disabled:opacity-70"
> >
{submitting ? '保存中...' : '保存项目'} {submitting ? '保存中...' : '保存项目'}
...@@ -2494,6 +2543,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -2494,6 +2543,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingTask, setEditingTask] = useState(null); const [editingTask, setEditingTask] = useState(null);
const [dialogSubmitting, setDialogSubmitting] = useState(false); const [dialogSubmitting, setDialogSubmitting] = useState(false);
const [dialogDeleting, setDialogDeleting] = useState(false);
const [dialogError, setDialogError] = useState(''); const [dialogError, setDialogError] = useState('');
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreviewRows, setImportPreviewRows] = useState([]); const [importPreviewRows, setImportPreviewRows] = useState([]);
...@@ -2731,12 +2781,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -2731,12 +2781,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingTask(null); setEditingTask(null);
setDialogDeleting(false);
setDialogError(''); setDialogError('');
setDialogOpen(true); setDialogOpen(true);
}; };
const openEditDialog = (task) => { const openEditDialog = (task) => {
setEditingTask(task || selectedTask || null); setEditingTask(task || selectedTask || null);
setDialogDeleting(false);
setDialogError(''); setDialogError('');
setDialogOpen(true); setDialogOpen(true);
}; };
...@@ -2756,6 +2808,31 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -2756,6 +2808,31 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
} }
}; };
const handleDialogDelete = async (taskToDelete) => {
if (!taskToDelete) {
setDialogError('未找到可删除的项目');
return;
}
const deletingTaskId = getTaskRecordId(taskToDelete);
setDialogDeleting(true);
setDialogError('');
try {
await deleteProjectTask(taskToDelete);
await loadWorkspace();
if (selectedTaskId && deletingTaskId === selectedTaskId) {
setSelectedTaskId('');
}
setDialogOpen(false);
setEditingTask(null);
} catch (requestError) {
setDialogError(requestError?.message || '删除失败,请稍后重试');
} finally {
setDialogDeleting(false);
}
};
const handleOpenTaskFromCalendar = (task) => { const handleOpenTaskFromCalendar = (task) => {
setSelectedTaskId(getTaskRecordId(task)); setSelectedTaskId(getTaskRecordId(task));
setActiveTab('gantt'); setActiveTab('gantt');
...@@ -2934,17 +3011,20 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -2934,17 +3011,20 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
open={dialogOpen} open={dialogOpen}
task={editingTask} task={editingTask}
submitting={dialogSubmitting} submitting={dialogSubmitting}
deleting={dialogDeleting}
submitError={dialogError} submitError={dialogError}
fieldOptions={fieldOptions} fieldOptions={fieldOptions}
onClose={() => { onClose={() => {
if (dialogSubmitting) { if (dialogSubmitting || dialogDeleting) {
return; return;
} }
setDialogOpen(false); setDialogOpen(false);
setEditingTask(null); setEditingTask(null);
setDialogDeleting(false);
setDialogError(''); setDialogError('');
}} }}
onSubmit={handleDialogSubmit} onSubmit={handleDialogSubmit}
onDelete={handleDialogDelete}
/> />
<ProjectImportDialog <ProjectImportDialog
......
...@@ -14,6 +14,8 @@ ...@@ -14,6 +14,8 @@
## 2. 功能入口 ## 2. 功能入口
- 侧栏顶部:`应用切换` 按钮,打开应用切换弹框,展示当前账号可访问的全部应用;点击应用卡片后进入对应工作区
- 移动端顶部:`应用切换` 图标按钮,打开同一应用切换弹框
- 左侧菜单:`资源统计` - 左侧菜单:`资源统计`
- 左侧菜单:`资源预定` - 左侧菜单:`资源预定`
...@@ -80,6 +82,8 @@ ...@@ -80,6 +82,8 @@
6. 项目完成时,如当前完成进度小于 100,可默认补到 100 6. 项目完成时,如当前完成进度小于 100,可默认补到 100
7. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的 `completion_percent` 7. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的 `completion_percent`
8. 若已超过计划结束日期仍未完成,补充 `delay_reason` 8. 若已超过计划结束日期仍未完成,补充 `delay_reason`
9. 对于已落库项目,可在编辑弹框内点击“删除当前任务”
10. 删除操作必须二次确认;确认后前端调用删除接口,按软删除口径将记录移出甘特图、日历图与项目清单
### 4.4 状态结果判定 ### 4.4 状态结果判定
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
loadUser, loadUser,
queryRecords, queryRecords,
queryUsers, queryUsers,
removeRecord,
updateRecord, updateRecord,
} from '@/services/DataService'; } from '@/services/DataService';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
...@@ -1179,6 +1180,17 @@ export async function saveProjectTask(values, currentTask) { ...@@ -1179,6 +1180,17 @@ export async function saveProjectTask(values, currentTask) {
}); });
} }
export async function deleteProjectTask(currentTask) {
const recordName =
currentTask?.name || currentTask?._id || currentTask?.id || '';
if (!recordName) {
throw new Error('未找到可删除的项目记录');
}
return removeRecord(recordName);
}
export function buildDemoTasks() { export function buildDemoTasks() {
return [ return [
{ {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment