diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index 35aa42e..978f547 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -16,7 +16,10 @@ instance.interceptors.request.use((config) => { instance.interceptors.response.use( (res) => res.data, (err) => { - if (err.response?.status === 401) { + const isLoginRequest = + err.config?.url === '/auth/login' || err.config?.url === 'auth/login'; + + if (err.response?.status === 401 && !isLoginRequest) { localStorage.removeItem('token'); localStorage.removeItem('user'); localStorage.removeItem('permissions'); diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index 53d3fdb..91e7fb1 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -3,6 +3,7 @@ import { Badge, Popover, Button, List, Typography, Empty } from 'antd'; import { BellOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import api from '../api'; +import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display'; interface NotificationItem { id: number; @@ -14,18 +15,6 @@ interface NotificationItem { createdAt: string; } -const typeLabels: Record = { - bill_generated: '账单', - bill_paid: '账单', - check_in: '入住', - check_out: '退宿', - deposit_due: '押金', - deposit_refunded: '押金', - class_change: '班级', - schedule_conflict: '排课', - announcement: '公告', -}; - function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); @@ -163,7 +152,7 @@ const NotificationBell: React.FC = () => { strong={!item.isRead} style={{ fontSize: 14 }} > - [{typeLabels[item.type] || item.type}] {item.title} + [{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)} } description={ diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 2792b04..16c29c0 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -17,7 +17,6 @@ import { import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; -import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; @@ -91,10 +90,9 @@ const DepositsPage: React.FC = () => { const studentOptions = useMemo( () => students - .filter((s: any) => s.status === 'active') .map((s: any) => ({ value: s.id, - label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`, + label: s.studentNo ? `${s.name} (${s.studentNo})` : s.name, })), [students], ); diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 9411be6..30f241c 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -31,6 +31,11 @@ import { message } from '../../ui/app-message'; const { RangePicker } = DatePicker; +const isFormValidationError = (error: unknown) => + typeof error === 'object' + && error !== null + && Array.isArray((error as { errorFields?: unknown }).errorFields); + const ExpensesPage: React.FC = () => { @@ -157,16 +162,16 @@ const ExpensesPage: React.FC = () => { const handleRoomExpense = async () => { setSaving(true); - const values = await roomForm.validateFields(); - const payload = { - roomId: values.roomId, - expenseType: values.expenseType, - amount: values.amount, - periodStart: values.period[0].format('YYYY-MM-DD'), - periodEnd: values.period[1].format('YYYY-MM-DD'), - description: values.description, - }; try { + const values = await roomForm.validateFields(); + const payload = { + roomId: values.roomId, + expenseType: values.expenseType, + amount: values.amount, + periodStart: values.period[0].format('YYYY-MM-DD'), + periodEnd: values.period[1].format('YYYY-MM-DD'), + description: values.description, + }; if (editingRoom) { await api.put(`/expenses/room/${editingRoom.id}`, payload); message.success('更新成功'); @@ -179,7 +184,9 @@ const ExpensesPage: React.FC = () => { roomForm.resetFields(); fetchData(); } catch (e: any) { - message.error(e?.message || '操作失败'); + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } } finally { setSaving(false); } @@ -187,16 +194,16 @@ const ExpensesPage: React.FC = () => { const handlePersonalExpense = async () => { setSaving(true); - const values = await personalForm.validateFields(); - const payload = { - studentId: values.studentId, - roomId: values.roomId, - expenseType: values.expenseType, - amount: values.amount, - expenseDate: values.expenseDate.format('YYYY-MM-DD'), - description: values.description, - }; try { + const values = await personalForm.validateFields(); + const payload = { + studentId: values.studentId, + roomId: values.roomId, + expenseType: values.expenseType, + amount: values.amount, + expenseDate: values.expenseDate.format('YYYY-MM-DD'), + description: values.description, + }; if (editingPersonal) { await api.put(`/expenses/personal/${editingPersonal.id}`, payload); message.success('更新成功'); @@ -209,7 +216,9 @@ const ExpensesPage: React.FC = () => { personalForm.resetFields(); fetchData(); } catch (e: any) { - message.error(e?.message || '操作失败'); + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } } finally { setSaving(false); } diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index e247956..0762ba0 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -10,6 +10,7 @@ import { import { useNavigate } from 'react-router-dom'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { formatNotificationText } from '../../utils/notification-display'; const { Sider, Content } = Layout; @@ -169,7 +170,7 @@ const NotificationsPage: React.FC = () => { strong={!item.isRead} style={{ fontSize: 15 }} > - {item.title} + {formatNotificationText(item.title)} {timeAgo(item.createdAt)} @@ -183,7 +184,7 @@ const NotificationsPage: React.FC = () => { ellipsis={{ rows: 1 }} style={{ marginBottom: 0 }} > - {item.content} + {formatNotificationText(item.content)} ) } diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index e5bfdc4..0eca35a 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -322,7 +322,28 @@ const StudentsPage: React.FC = () => { }, { title: '民族', dataIndex: 'ethnicity', width: 90 }, { title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 }, - { title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 }, + { + title: '紧急联系人电话', + dataIndex: 'emergencyPhone', + width: 150, + render: (v: string, record: any) => { + if (!v) return '-'; + return ( + + {maskPhone(v)} + + + ); + }, + }, { title: '所属机构', dataIndex: 'organization', diff --git a/apps/admin/src/utils/notification-display.ts b/apps/admin/src/utils/notification-display.ts new file mode 100644 index 0000000..909ec64 --- /dev/null +++ b/apps/admin/src/utils/notification-display.ts @@ -0,0 +1,25 @@ +export const notificationTypeLabels: Record = { + bill_generated: '账单', + bill_paid: '账单', + check_in: '入住', + check_out: '退宿', + deposit_due: '押金', + deposit_refunded: '押金', + class_change: '班级', + schedule_conflict: '排课', + announcement: '公告', +}; + +const teacherRoleLabels: Record = { + subject_teacher: '任课老师', + head_teacher: '班主任', + life_teacher: '生活老师', + academic_teacher: '学服老师', +}; + +export function formatNotificationText(text: string): string { + return Object.entries(teacherRoleLabels).reduce( + (result, [roleType, label]) => result.replaceAll(roleType, label), + text, + ); +} diff --git a/apps/server/package.json b/apps/server/package.json index 39fbd54..095d893 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,7 +6,7 @@ "license": "UNLICENSED", "author": "", "scripts": { - "dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json", + "dev": "cross-env SEED_DEV=true nest start --watch -p tsconfig.build.json", "build": "nest build -p tsconfig.build.json", "start:dev": "nest start --watch", "format": "oxfmt", @@ -67,6 +67,7 @@ "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/supertest": "^7.0.0", + "cross-env": "^10.1.0", "eslint": "^9.18.0", "globals": "^17.0.0", "jest": "^30.0.0", diff --git a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts index a204938..e3f4135 100644 --- a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts +++ b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts @@ -12,6 +12,7 @@ describe('DingTalkService — attendance records', () => { service = new DingTalkService({} as never, {} as never); Object.assign(service, { accessToken: 'test-token', + accessTokenCredentialKey: 'test-app-key:test-app-secret', tokenExpiresAt: Date.now() + 3_600_000, }); }); diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index d79af3d..1b121a9 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -29,6 +29,7 @@ import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; +import { TeacherRoleType } from '../entities'; import * as ExcelJS from 'exceljs'; import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization'; @@ -36,6 +37,13 @@ interface AuthenticatedRequest { user: AuthenticatedUser; } +const teacherRoleLabels: Record = { + [TeacherRoleType.SUBJECT_TEACHER]: '任课老师', + [TeacherRoleType.HEAD_TEACHER]: '班主任', + [TeacherRoleType.LIFE_TEACHER]: '生活老师', + [TeacherRoleType.ACADEMIC_TEACHER]: '学服老师', +}; + @UseGuards(JwtAuthGuard) @Controller('classes') export class ClassesController { @@ -302,7 +310,7 @@ export class ClassesController { recipientIds: [dto.userId], type: NotificationType.CLASS_CHANGE, title: '班级分配', - content: `您已被分配到班级担任${dto.roleType}角色`, + content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`, }); } catch {} return result; diff --git a/apps/server/src/integration/dingtalk.group-delete.spec.ts b/apps/server/src/integration/dingtalk.group-delete.spec.ts index 00159b0..f58f9bb 100644 --- a/apps/server/src/integration/dingtalk.group-delete.spec.ts +++ b/apps/server/src/integration/dingtalk.group-delete.spec.ts @@ -11,6 +11,7 @@ describe('DingTalkService attendance group deletion', () => { service = new DingTalkService({} as never, {} as never); Object.assign(service, { accessToken: 'test-token', + accessTokenCredentialKey: 'test-app-key:test-app-secret', tokenExpiresAt: Date.now() + 3_600_000, }); }); diff --git a/apps/server/src/integration/dingtalk.service.spec.ts b/apps/server/src/integration/dingtalk.service.spec.ts index 1e35980..fade60f 100644 --- a/apps/server/src/integration/dingtalk.service.spec.ts +++ b/apps/server/src/integration/dingtalk.service.spec.ts @@ -12,6 +12,7 @@ describe('DingTalkService — queryShifts', () => { service = new DingTalkService({} as never, {} as never); Object.assign(service, { accessToken: 'test-token', + accessTokenCredentialKey: 'test-app-key:test-app-secret', tokenExpiresAt: Date.now() + 3_600_000, }); jest.spyOn(service, 'rateLimit').mockResolvedValue(undefined); @@ -105,6 +106,7 @@ describe('DingTalkService — attendance machine only group', () => { service = new DingTalkService({} as never, {} as never); Object.assign(service, { accessToken: 'test-token', + accessTokenCredentialKey: 'test-app-key:test-app-secret', tokenExpiresAt: Date.now() + 3_600_000, }); jest.spyOn(service, 'rateLimit').mockResolvedValue(undefined); diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 3e01989..a08cf4e 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { IntegrationConfigService } from './config/integration-config.service'; // ── Types ── @@ -18,6 +19,11 @@ interface DingTalkTokenResponse { expireIn: number; } +interface DingTalkCredentials { + appKey: string; + appSecret: string; +} + interface DingTalkUserListResponse { errcode: number; errmsg: string; @@ -168,6 +174,7 @@ export interface DingTalkScheduleResult { export class DingTalkService { private readonly logger = new Logger(DingTalkService.name); private accessToken: string | null = null; + private accessTokenCredentialKey: string | null = null; private tokenExpiresAt = 0; private apiRequestCount = 0; @@ -180,10 +187,28 @@ export class DingTalkService { private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, + private readonly integrationConfigService?: IntegrationConfigService, ) {} - private get configured(): boolean { - return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); + private async getCredentials(): Promise { + const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK'); + const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : ''; + const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : ''; + if (dbAppKey && dbAppSecret) { + return { appKey: dbAppKey, appSecret: dbAppSecret }; + } + + const envAppKey = process.env.DINGTALK_APP_KEY?.trim(); + const envAppSecret = process.env.DINGTALK_APP_SECRET?.trim(); + if (envAppKey && envAppSecret) { + return { appKey: envAppKey, appSecret: envAppSecret }; + } + + return null; + } + + private async isConfigured(): Promise { + return !!(await this.getCredentials()); } // ═══════════════════════════════════════════ @@ -191,16 +216,24 @@ export class DingTalkService { // ═══════════════════════════════════════════ private async getAccessToken(): Promise { - if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + const credentials = await this.getCredentials(); + if (!credentials) { + throw new Error('DingTalk not configured'); + } + + const credentialKey = `${credentials.appKey}:${credentials.appSecret}`; + if ( + this.accessToken && + this.accessTokenCredentialKey === credentialKey && + Date.now() < this.tokenExpiresAt - 60_000 + ) { return this.accessToken; } - const appKey = process.env.DINGTALK_APP_KEY!; - const appSecret = process.env.DINGTALK_APP_SECRET!; const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ appKey, appSecret }), + body: JSON.stringify(credentials), }); const body: DingTalkTokenResponse = await res.json(); @@ -209,6 +242,7 @@ export class DingTalkService { } this.accessToken = body.accessToken; + this.accessTokenCredentialKey = credentialKey; this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; this.logger.log('钉钉 access_token 获取成功'); return this.accessToken; @@ -259,7 +293,7 @@ export class DingTalkService { // ═══════════════════════════════════════════ async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { - if (!this.configured) { + if (!(await this.isConfigured())) { this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步'); return { deptCount: 0, userCount: 0 }; } @@ -333,7 +367,7 @@ export class DingTalkService { /** 获取钉钉组织部门树(只含部门) */ async fetchOrgTree(rootDeptId = 1): Promise { - if (!this.configured) return []; + if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; @@ -343,7 +377,7 @@ export class DingTalkService { /** 获取钉钉组织部门树(含用户) */ async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { - if (!this.configured) return []; + if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; @@ -441,7 +475,7 @@ export class DingTalkService { endDate: string; userIds?: string[]; }): Promise { - if (!this.configured) throw new Error('DingTalk not configured'); + if (!(await this.isConfigured())) throw new Error('DingTalk not configured'); if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空'); if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人'); const token = await this.getAccessToken(); @@ -496,7 +530,7 @@ export class DingTalkService { /** 创建或修改班次。id 不传=创建,传了=修改 */ async upsertShift(params: DingTalkShiftParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const body: Record = { @@ -547,7 +581,7 @@ export class DingTalkService { /** 查询所有班次摘要(每页最多200条) */ async queryShifts(opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkShiftSummary[] = []; @@ -598,7 +632,7 @@ export class DingTalkService { /** 创建排班制考勤组 */ async createAttendanceGroup(params: DingTalkGroupParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = this.buildAttendanceGroupBody(params); @@ -627,7 +661,7 @@ export class DingTalkService { /** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */ async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id }; @@ -687,7 +721,7 @@ export class DingTalkService { /** 查询所有考勤组摘要(分页,每页10条) */ async queryAttendanceGroups(opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkGroupSummary[] = []; @@ -729,7 +763,7 @@ export class DingTalkService { } async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); @@ -778,7 +812,7 @@ export class DingTalkService { async scheduleUsers( groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', ): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); if (schedules.length === 0) return; if (schedules.length > 200) { throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`); @@ -818,7 +852,7 @@ export class DingTalkService { async queryScheduleByUsers( userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', ): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); diff --git a/apps/server/src/integration/integration.module.ts b/apps/server/src/integration/integration.module.ts index 6c98c9e..e003536 100644 --- a/apps/server/src/integration/integration.module.ts +++ b/apps/server/src/integration/integration.module.ts @@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User, Student, StudentDingMapping, Class } from '../entities'; import { DingTalkService } from './dingtalk.service'; import { WeComService } from './wecom.service'; +import { IntegrationConfigModule } from './config/config.module'; @Module({ - imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])], + imports: [ + TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class]), + IntegrationConfigModule, + ], providers: [DingTalkService, WeComService], exports: [DingTalkService, WeComService], }) diff --git a/package-lock.json b/package-lock.json index 53f3159..fb3aab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,6 +98,7 @@ "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/supertest": "^7.0.0", + "cross-env": "^10.1.0", "eslint": "^9.18.0", "globals": "^17.0.0", "jest": "^30.0.0", @@ -1133,6 +1134,13 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT" }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -8697,6 +8705,24 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",