Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -208,7 +208,9 @@ export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRe
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
params: { refreshToken },
|
params: { refreshToken },
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
skipAuth: true
|
skipAuth: true,
|
||||||
|
suppressErrorMessage: true,
|
||||||
|
skipTokenRefresh: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error || !result.data) {
|
if (result.error || !result.data) {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ declare module 'axios' {
|
|||||||
* 避免给它们带上过期 access 头被网关拦截。
|
* 避免给它们带上过期 access 头被网关拦截。
|
||||||
*/
|
*/
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
|
/** 请求失败时不走通用错误 toast,由调用方自行收敛提示。 */
|
||||||
|
suppressErrorMessage?: boolean;
|
||||||
|
/** 请求失败命中过期 access code 时,不再触发 refresh-token 流程。 */
|
||||||
|
skipTokenRefresh?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
src/service/request/error-message.ts
Normal file
32
src/service/request/error-message.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const SESSION_EXPIRED_MESSAGE = '登录已失效,请重新登录';
|
||||||
|
|
||||||
|
export interface ErrorMessageSuppressOptions {
|
||||||
|
backendErrorCode: string;
|
||||||
|
suppressErrorMessage?: boolean;
|
||||||
|
logoutCodes: string[];
|
||||||
|
modalLogoutCodes: string[];
|
||||||
|
expiredTokenCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendFailDeferOptions {
|
||||||
|
suppressErrorMessage?: boolean;
|
||||||
|
skipTokenRefresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServiceCodes(codes?: string) {
|
||||||
|
return codes?.split(',').filter(Boolean) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDeferBackendFailToCaller(options: BackendFailDeferOptions) {
|
||||||
|
return Boolean(options.suppressErrorMessage && options.skipTokenRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSuppressErrorMessage(options: ErrorMessageSuppressOptions) {
|
||||||
|
if (options.suppressErrorMessage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handledCodes = [...options.logoutCodes, ...options.modalLogoutCodes, ...options.expiredTokenCodes];
|
||||||
|
|
||||||
|
return handledCodes.includes(options.backendErrorCode);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { localStg } from '@/utils/storage';
|
|||||||
import { getServiceBaseURL } from '@/utils/service';
|
import { getServiceBaseURL } from '@/utils/service';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
import { applyApiEncrypt } from './api-encrypt';
|
import { applyApiEncrypt } from './api-encrypt';
|
||||||
|
import { parseServiceCodes, shouldDeferBackendFailToCaller, shouldSuppressErrorMessage } from './error-message';
|
||||||
import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
|
import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
|
||||||
import { withDedupe } from './dedupe';
|
import { withDedupe } from './dedupe';
|
||||||
import type { RequestInstanceState } from './type';
|
import type { RequestInstanceState } from './type';
|
||||||
@@ -48,6 +49,15 @@ export const request = withDedupe(
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const responseCode = String(response.data.code);
|
const responseCode = String(response.data.code);
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldDeferBackendFailToCaller({
|
||||||
|
suppressErrorMessage: response.config.suppressErrorMessage,
|
||||||
|
skipTokenRefresh: response.config.skipTokenRefresh
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
authStore.resetStore();
|
authStore.resetStore();
|
||||||
}
|
}
|
||||||
@@ -61,14 +71,14 @@ export const request = withDedupe(
|
|||||||
|
|
||||||
// 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
|
// 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
|
||||||
// 走 notifySessionExpired 而不是裸 resetStore:保证并发请求只弹一次 toast、只清一次状态
|
// 走 notifySessionExpired 而不是裸 resetStore:保证并发请求只弹一次 toast、只清一次状态
|
||||||
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
|
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
|
||||||
if (logoutCodes.includes(responseCode)) {
|
if (logoutCodes.includes(responseCode)) {
|
||||||
notifySessionExpired();
|
notifySessionExpired();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
|
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
|
||||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
|
||||||
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
||||||
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
|
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
|
||||||
|
|
||||||
@@ -92,8 +102,13 @@ export const request = withDedupe(
|
|||||||
|
|
||||||
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
|
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
|
||||||
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
|
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
|
||||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
|
||||||
if (expiredTokenCodes.includes(responseCode)) {
|
if (expiredTokenCodes.includes(responseCode)) {
|
||||||
|
if (response.config.skipTokenRefresh) {
|
||||||
|
notifySessionExpired();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const success = await handleExpiredRequest(request.state);
|
const success = await handleExpiredRequest(request.state);
|
||||||
if (success) {
|
if (success) {
|
||||||
const Authorization = getAuthorization();
|
const Authorization = getAuthorization();
|
||||||
@@ -117,15 +132,19 @@ export const request = withDedupe(
|
|||||||
backendErrorCode = String(error.response?.data?.code || '');
|
backendErrorCode = String(error.response?.data?.code || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这类错误信息已经通过弹窗展示,不再重复提示
|
const suppressErrorMessage = Boolean(error.config?.suppressErrorMessage);
|
||||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
|
||||||
if (modalLogoutCodes.includes(backendErrorCode)) {
|
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
|
||||||
return;
|
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
|
||||||
}
|
if (
|
||||||
|
shouldSuppressErrorMessage({
|
||||||
// token 过期时会自动刷新并重试请求,这里无需额外提示
|
backendErrorCode,
|
||||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
suppressErrorMessage,
|
||||||
if (expiredTokenCodes.includes(backendErrorCode)) {
|
logoutCodes,
|
||||||
|
modalLogoutCodes,
|
||||||
|
expiredTokenCodes
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useAuthStore } from '@/store/modules/auth';
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
import { localStg } from '@/utils/storage';
|
import { localStg } from '@/utils/storage';
|
||||||
import { fetchRefreshToken } from '../api';
|
import { fetchRefreshToken } from '../api';
|
||||||
|
import { SESSION_EXPIRED_MESSAGE } from './error-message';
|
||||||
import type { RequestInstanceState } from './type';
|
import type { RequestInstanceState } from './type';
|
||||||
|
|
||||||
export function getAuthorization() {
|
export function getAuthorization() {
|
||||||
@@ -51,7 +52,7 @@ export function notifySessionExpired() {
|
|||||||
if (sessionExpiredNotified) return;
|
if (sessionExpiredNotified) return;
|
||||||
sessionExpiredNotified = true;
|
sessionExpiredNotified = true;
|
||||||
|
|
||||||
window.$message?.error('登录已过期,请重新登录');
|
window.$message?.error(SESSION_EXPIRED_MESSAGE);
|
||||||
|
|
||||||
const { resetStore } = useAuthStore();
|
const { resetStore } = useAuthStore();
|
||||||
resetStore();
|
resetStore();
|
||||||
|
|||||||
@@ -51,16 +51,27 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
|
|
||||||
clearAuthStorage();
|
clearAuthStorage();
|
||||||
|
|
||||||
authStore.$reset();
|
// setup store 没有内置 $reset,需要显式重置内部状态,避免 token / userInfo 残留导致 isLogin 误判。
|
||||||
dictStore.resetDictCache();
|
token.value = '';
|
||||||
objectContextStore.$reset();
|
Object.assign(userInfo, {
|
||||||
|
userId: '',
|
||||||
|
userName: '',
|
||||||
|
nickname: '',
|
||||||
|
roles: [],
|
||||||
|
buttons: []
|
||||||
|
});
|
||||||
|
|
||||||
if (!route.meta.constant) {
|
dictStore.resetDictCache();
|
||||||
|
objectContextStore.clearContext();
|
||||||
|
|
||||||
|
// 用路由名判断当前是否已在登录页,避免依赖 route.meta.constant ——
|
||||||
|
// workbench 等首页也是常量路由,原写法会让常量路由上的登出请求不跳转。
|
||||||
|
if (route.name !== 'login') {
|
||||||
await toLogin();
|
await toLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
tabStore.cacheTabs();
|
tabStore.cacheTabs();
|
||||||
routeStore.resetStore();
|
await routeStore.resetStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */
|
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */
|
||||||
|
|||||||
@@ -149,9 +149,16 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
|
|
||||||
/** 重置 store */
|
/** 重置 store */
|
||||||
async function resetStore() {
|
async function resetStore() {
|
||||||
const routeStore = useRouteStore();
|
// setup store 没有内置 $reset,需要显式重置内部状态。
|
||||||
|
// 否则 isInitConstantRoute / isInitAuthRoute 一直停在 true,导致下面 initConstantRoute 早返,
|
||||||
routeStore.$reset();
|
// 路由被 resetVueRoutes 摘掉后无法重新注册,菜单和导航都会失效。
|
||||||
|
setIsInitConstantRoute(false);
|
||||||
|
setIsInitAuthRoute(false);
|
||||||
|
constantRoutes.value = [];
|
||||||
|
authRoutes.value = [];
|
||||||
|
menus.value = [];
|
||||||
|
cacheRoutes.value = [];
|
||||||
|
excludeCacheRoutes.value = [];
|
||||||
|
|
||||||
resetVueRoutes();
|
resetVueRoutes();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
|||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import { ElButton, ElTag } from 'element-plus';
|
import { ElButton, ElTag } from 'element-plus';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
|
import { Box, DeleteFilled, VideoPause, VideoPlay } from '@element-plus/icons-vue';
|
||||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||||
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
||||||
@@ -76,14 +76,14 @@ const statusNavMetas: StatusNavMeta[] = [
|
|||||||
label: '启用产品',
|
label: '启用产品',
|
||||||
description: '当前正常服务中的产品',
|
description: '当前正常服务中的产品',
|
||||||
tone: 'teal',
|
tone: 'teal',
|
||||||
icon: CircleCheckFilled
|
icon: VideoPlay
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'archived',
|
key: 'archived',
|
||||||
label: '归档产品',
|
label: '归档产品',
|
||||||
description: '已完成阶段目标的产品',
|
description: '已完成阶段目标的产品',
|
||||||
tone: 'slate',
|
tone: 'slate',
|
||||||
icon: FolderOpened
|
icon: Box
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'paused',
|
key: 'paused',
|
||||||
@@ -109,7 +109,7 @@ const operateVisible = ref(false);
|
|||||||
const editingRow = ref<Api.Product.Product | null>(null);
|
const editingRow = ref<Api.Product.Product | null>(null);
|
||||||
const { routerPush } = useRouterPush();
|
const { routerPush } = useRouterPush();
|
||||||
|
|
||||||
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
|
||||||
const statusCounts = ref<Record<string, number>>({
|
const statusCounts = ref<Record<string, number>>({
|
||||||
active: 0,
|
active: 0,
|
||||||
@@ -129,29 +129,6 @@ const statusItems = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const overviewMetrics = computed(() => [
|
|
||||||
{
|
|
||||||
label: '可见产品',
|
|
||||||
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
|
|
||||||
hint: '当前接口可查询到的产品总量'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '当前启用',
|
|
||||||
value: statusCounts.value.active ?? 0,
|
|
||||||
hint: '正在持续服务和维护的产品'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '产品方向',
|
|
||||||
value: directionOptions.value.length,
|
|
||||||
hint: '已加载的方向字典项数量'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '废弃产品',
|
|
||||||
value: statusCounts.value.abandoned ?? 0,
|
|
||||||
hint: '已明确停止建设的产品'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getDirectionLabel(directionCode?: string | null) {
|
function getDirectionLabel(directionCode?: string | null) {
|
||||||
return getDirectionDictLabel(directionCode, '--');
|
return getDirectionDictLabel(directionCode, '--');
|
||||||
}
|
}
|
||||||
@@ -288,7 +265,7 @@ async function handleResetSearch() {
|
|||||||
|
|
||||||
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
|
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
|
||||||
selectedStatus.value = status;
|
selectedStatus.value = status;
|
||||||
await reloadProductTable(1);
|
await Promise.all([loadOverviewData(), reloadProductTable(1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -326,14 +303,6 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||||
<ElCard class="product-overview-card card-wrapper">
|
<ElCard class="product-overview-card card-wrapper">
|
||||||
<div class="product-overview-card__stats">
|
|
||||||
<div v-for="item in overviewMetrics" :key="item.label" class="product-overview-card__stat">
|
|
||||||
<span class="product-overview-card__stat-label">{{ item.label }}</span>
|
|
||||||
<strong class="product-overview-card__stat-value">{{ item.value }}</strong>
|
|
||||||
<small class="product-overview-card__stat-hint">{{ item.hint }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="product-status-panel__list">
|
<div class="product-status-panel__list">
|
||||||
<button
|
<button
|
||||||
v-for="item in statusItems"
|
v-for="item in statusItems"
|
||||||
@@ -441,45 +410,10 @@ onMounted(async () => {
|
|||||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-overview-card__stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-overview-card__stat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid rgb(226 232 240 / 88%);
|
|
||||||
border-radius: 18px;
|
|
||||||
background-color: rgb(255 255 255 / 84%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-overview-card__stat-label {
|
|
||||||
color: rgb(100 116 139 / 90%);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-overview-card__stat-value {
|
|
||||||
color: rgb(15 23 42 / 94%);
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-overview-card__stat-hint {
|
|
||||||
color: rgb(100 116 139 / 90%);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-status-panel__list {
|
.product-status-panel__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-status-item {
|
.product-status-item {
|
||||||
@@ -594,10 +528,4 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 640px) {
|
|
||||||
.product-overview-card__stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const operateVisible = ref(false);
|
|||||||
const editingRow = ref<Api.Project.Project | null>(null);
|
const editingRow = ref<Api.Project.Project | null>(null);
|
||||||
const { routerPush } = useRouterPush();
|
const { routerPush } = useRouterPush();
|
||||||
|
|
||||||
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
||||||
|
|
||||||
const statusCounts = ref<Record<string, number>>({
|
const statusCounts = ref<Record<string, number>>({
|
||||||
@@ -243,7 +243,7 @@ async function handleResetSearch() {
|
|||||||
|
|
||||||
async function handleStatusChange(status: Api.Project.ProjectStatusCode) {
|
async function handleStatusChange(status: Api.Project.ProjectStatusCode) {
|
||||||
selectedStatus.value = status;
|
selectedStatus.value = status;
|
||||||
await reloadProjectTable(1);
|
await Promise.all([loadOverviewData(), reloadProjectTable(1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -282,7 +282,6 @@ onMounted(async () => {
|
|||||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||||
<ProjectOverviewCard
|
<ProjectOverviewCard
|
||||||
:status-counts="statusCounts"
|
:status-counts="statusCounts"
|
||||||
:direction-count="directionOptions.length"
|
|
||||||
:selected-status="selectedStatus"
|
:selected-status="selectedStatus"
|
||||||
@status-change="handleStatusChange"
|
@status-change="handleStatusChange"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import { CircleCheckFilled, DeleteFilled, DocumentAdd, FolderOpened, VideoPause } from '@element-plus/icons-vue';
|
import { Box, CircleCheckFilled, DeleteFilled, DocumentAdd, VideoPause, VideoPlay } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ProjectOverviewCard' });
|
defineOptions({ name: 'ProjectOverviewCard' });
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ interface StatusNavMeta {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
statusCounts: Record<string, number>;
|
statusCounts: Record<string, number>;
|
||||||
directionCount: number;
|
|
||||||
selectedStatus: Api.Project.ProjectStatusCode;
|
selectedStatus: Api.Project.ProjectStatusCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ const statusNavMetas: StatusNavMeta[] = [
|
|||||||
label: '进行中',
|
label: '进行中',
|
||||||
description: '正在执行的项目',
|
description: '正在执行的项目',
|
||||||
tone: 'teal',
|
tone: 'teal',
|
||||||
icon: CircleCheckFilled
|
icon: VideoPlay
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'paused',
|
key: 'paused',
|
||||||
@@ -54,7 +53,7 @@ const statusNavMetas: StatusNavMeta[] = [
|
|||||||
label: '已完成',
|
label: '已完成',
|
||||||
description: '达成目标的项目',
|
description: '达成目标的项目',
|
||||||
tone: 'teal',
|
tone: 'teal',
|
||||||
icon: FolderOpened
|
icon: CircleCheckFilled
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cancelled',
|
key: 'cancelled',
|
||||||
@@ -68,7 +67,7 @@ const statusNavMetas: StatusNavMeta[] = [
|
|||||||
label: '归档项目',
|
label: '归档项目',
|
||||||
description: '已收口归档的历史项目',
|
description: '已收口归档的历史项目',
|
||||||
tone: 'slate',
|
tone: 'slate',
|
||||||
icon: FolderOpened
|
icon: Box
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -79,29 +78,6 @@ const statusItems = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const overviewMetrics = computed(() => [
|
|
||||||
{
|
|
||||||
label: '总项目数',
|
|
||||||
value: Object.values(props.statusCounts).reduce((sum, count) => sum + count, 0),
|
|
||||||
hint: '当前接口可查询到的项目总量'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '进行中',
|
|
||||||
value: props.statusCounts.active ?? 0,
|
|
||||||
hint: '正在执行的项目'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '待开始',
|
|
||||||
value: props.statusCounts.pending ?? 0,
|
|
||||||
hint: '等待启动的项目'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '作废项目',
|
|
||||||
value: props.statusCounts.cancelled ?? 0,
|
|
||||||
hint: '已终止或取消推进的项目'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
function handleStatusClick(status: Api.Project.ProjectStatusCode) {
|
function handleStatusClick(status: Api.Project.ProjectStatusCode) {
|
||||||
emit('status-change', status);
|
emit('status-change', status);
|
||||||
}
|
}
|
||||||
@@ -109,14 +85,6 @@ function handleStatusClick(status: Api.Project.ProjectStatusCode) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElCard class="project-overview-card card-wrapper">
|
<ElCard class="project-overview-card card-wrapper">
|
||||||
<div class="project-overview-card__stats">
|
|
||||||
<div v-for="item in overviewMetrics" :key="item.label" class="project-overview-card__stat">
|
|
||||||
<span class="project-overview-card__stat-label">{{ item.label }}</span>
|
|
||||||
<strong class="project-overview-card__stat-value">{{ item.value }}</strong>
|
|
||||||
<small class="project-overview-card__stat-hint">{{ item.hint }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="project-status-panel__list">
|
<div class="project-status-panel__list">
|
||||||
<button
|
<button
|
||||||
v-for="item in statusItems"
|
v-for="item in statusItems"
|
||||||
@@ -153,45 +121,10 @@ function handleStatusClick(status: Api.Project.ProjectStatusCode) {
|
|||||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-overview-card__stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-overview-card__stat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid rgb(226 232 240 / 88%);
|
|
||||||
border-radius: 18px;
|
|
||||||
background-color: rgb(255 255 255 / 84%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-overview-card__stat-label {
|
|
||||||
color: rgb(100 116 139 / 90%);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-overview-card__stat-value {
|
|
||||||
color: rgb(15 23 42 / 94%);
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-overview-card__stat-hint {
|
|
||||||
color: rgb(100 116 139 / 90%);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-status-panel__list {
|
.project-status-panel__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-status-item {
|
.project-status-item {
|
||||||
@@ -293,10 +226,4 @@ function handleStatusClick(status: Api.Project.ProjectStatusCode) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 640px) {
|
|
||||||
.project-overview-card__stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
78
tests/request-session-expired-message.test.ts
Normal file
78
tests/request-session-expired-message.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import {
|
||||||
|
SESSION_EXPIRED_MESSAGE,
|
||||||
|
shouldDeferBackendFailToCaller,
|
||||||
|
shouldSuppressErrorMessage
|
||||||
|
} from '../src/service/request/error-message';
|
||||||
|
|
||||||
|
describe('request session expired message handling', () => {
|
||||||
|
it('uses the unified session expired message', () => {
|
||||||
|
assert.equal(SESSION_EXPIRED_MESSAGE, '登录已失效,请重新登录');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses raw backend errors when the request is marked silent', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressErrorMessage({
|
||||||
|
backendErrorCode: '500',
|
||||||
|
suppressErrorMessage: true,
|
||||||
|
logoutCodes: [],
|
||||||
|
modalLogoutCodes: [],
|
||||||
|
expiredTokenCodes: []
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets refresh-token callers handle every backend failure silently', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldDeferBackendFailToCaller({
|
||||||
|
suppressErrorMessage: true,
|
||||||
|
skipTokenRefresh: true
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses auth lifecycle error codes handled by request flow', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressErrorMessage({
|
||||||
|
backendErrorCode: '401',
|
||||||
|
logoutCodes: ['401'],
|
||||||
|
modalLogoutCodes: [],
|
||||||
|
expiredTokenCodes: []
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressErrorMessage({
|
||||||
|
backendErrorCode: '7777',
|
||||||
|
logoutCodes: [],
|
||||||
|
modalLogoutCodes: ['7777'],
|
||||||
|
expiredTokenCodes: []
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressErrorMessage({
|
||||||
|
backendErrorCode: '1002023000',
|
||||||
|
logoutCodes: [],
|
||||||
|
modalLogoutCodes: [],
|
||||||
|
expiredTokenCodes: ['1002023000']
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps normal backend errors visible', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressErrorMessage({
|
||||||
|
backendErrorCode: '500',
|
||||||
|
logoutCodes: ['401'],
|
||||||
|
modalLogoutCodes: ['7777'],
|
||||||
|
expiredTokenCodes: ['1002023000']
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user