Compare commits
6 Commits
2ea00a5383
...
demo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b75437423 | ||
|
|
a472e543ce | ||
|
|
0c258dd1a2 | ||
|
|
200172f0af | ||
|
|
a954fb90f6 | ||
|
|
cf8f7cf969 |
@@ -13,6 +13,7 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY src/server ./src/server
|
COPY src/server ./src/server
|
||||||
|
COPY src/shared ./src/shared
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|||||||
10
src/App.tsx
10
src/App.tsx
@@ -7,11 +7,7 @@ import SchedulingModule from './modules/scheduling/SchedulingModule';
|
|||||||
import AuthProvider from './auth/AuthProvider';
|
import AuthProvider from './auth/AuthProvider';
|
||||||
import { useAuth } from './auth/useAuth';
|
import { useAuth } from './auth/useAuth';
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||||
|
import { canAccessScheduling } from './shared/auth/roles';
|
||||||
const SCHEDULING_ALLOWED_USERS = new Set([
|
|
||||||
'1105261382487539712',
|
|
||||||
'1116631120763437056',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const BASE_MODULES: ModuleConfig[] = [
|
const BASE_MODULES: ModuleConfig[] = [
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
@@ -26,11 +22,11 @@ function AuthGate() {
|
|||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||||
|
|
||||||
const modules = useMemo(() => {
|
const modules = useMemo(() => {
|
||||||
if (user?.userId && SCHEDULING_ALLOWED_USERS.has(user.userId)) {
|
if (canAccessScheduling(user?.roles)) {
|
||||||
return [...BASE_MODULES, SCHEDULING_MODULE];
|
return [...BASE_MODULES, SCHEDULING_MODULE];
|
||||||
}
|
}
|
||||||
return BASE_MODULES;
|
return BASE_MODULES;
|
||||||
}, [user?.userId]);
|
}, [user?.roles]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const jumpToken = params.get('jumpToken');
|
const jumpToken = params.get('jumpToken');
|
||||||
|
|
||||||
if (!jumpToken) {
|
if (!jumpToken) {
|
||||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
|
// 演示模式:无 token 时直接放行
|
||||||
|
setState({ isLoading: false, isAuthenticated: true, user: null, error: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { createContext, useContext } from 'react';
|
|||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
user: { userId: string; userName: string; permissionLevel: string; depName: string } | null;
|
user: {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
depName: string;
|
||||||
|
roles?: string[];
|
||||||
|
} | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DemoModeProvider enabled={false}>
|
<DemoModeProvider enabled={true}>
|
||||||
|
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* 全局水印 */}
|
{/* 全局水印 */}
|
||||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
|
|||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
|
<div className="text-base font-black text-slate-800">{fmtKm(v.currentYearMileage)}<span className="text-[9px] text-slate-400 ml-0.5">km</span></div>
|
||||||
<div className="text-[10px] text-slate-400">考核 {fmtKm(v.yearTarget)} km</div>
|
<div className="text-[10px] text-slate-400">考核 {fmtKm(v.yearTarget)} km</div>
|
||||||
|
<div className="text-[10px] text-slate-400">年度考核剩余 <b className="text-slate-700">{v.daysLeft}</b> 天</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
|
<div className="flex items-center gap-3 mt-2.5 text-[10px] text-slate-500">
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ app.get('/exchange', async (c) => {
|
|||||||
depCode: userInfo.depCode,
|
depCode: userInfo.depCode,
|
||||||
depName,
|
depName,
|
||||||
permissionLevel,
|
permissionLevel,
|
||||||
|
roles: roleNames,
|
||||||
};
|
};
|
||||||
|
|
||||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { JwtPayload, AuthUser } from './types.js';
|
|||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||||
|
|
||||||
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
// 演示分支:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||||
const BYPASS_AUTH = false;
|
const BYPASS_AUTH = true;
|
||||||
|
|
||||||
export async function authMiddleware(c: Context, next: Next) {
|
export async function authMiddleware(c: Context, next: Next) {
|
||||||
const path = c.req.path;
|
const path = c.req.path;
|
||||||
@@ -35,6 +35,7 @@ export async function authMiddleware(c: Context, next: Next) {
|
|||||||
depCode: payload.depCode,
|
depCode: payload.depCode,
|
||||||
depName: payload.depName,
|
depName: payload.depName,
|
||||||
permissionLevel: payload.permissionLevel,
|
permissionLevel: payload.permissionLevel,
|
||||||
|
roles: payload.roles ?? [],
|
||||||
};
|
};
|
||||||
c.set('user', user);
|
c.set('user', user);
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface AuthUser {
|
|||||||
depCode: string;
|
depCode: string;
|
||||||
depName: string;
|
depName: string;
|
||||||
permissionLevel: PermissionLevel;
|
permissionLevel: PermissionLevel;
|
||||||
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
@@ -16,12 +17,16 @@ export interface JwtPayload {
|
|||||||
depCode: string;
|
depCode: string;
|
||||||
depName: string;
|
depName: string;
|
||||||
permissionLevel: PermissionLevel;
|
permissionLevel: PermissionLevel;
|
||||||
|
roles: string[];
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全量权限角色名 */
|
// Re-export role constants and helpers from the shared module so existing
|
||||||
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
|
// server imports (`from './types.js'`) keep working.
|
||||||
|
export {
|
||||||
/** 部门级权限角色名 */
|
FULL_ACCESS_ROLES,
|
||||||
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
DEPT_ACCESS_ROLES,
|
||||||
|
SCHEDULING_ACCESS_ROLES,
|
||||||
|
canAccessScheduling,
|
||||||
|
} from '../../shared/auth/roles.js';
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function generateSuggestions(
|
|||||||
const reason: ReasonBlock = {
|
const reason: ReasonBlock = {
|
||||||
lines: [
|
lines: [
|
||||||
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
||||||
{ label: '考核剩余', value: `${vehicle.daysLeft} 天` },
|
{ label: '年度考核剩余', value: `${vehicle.daysLeft} 天` },
|
||||||
{ label: '日均需', value: `${fmtKmSimple(dailyReq)} km` },
|
{ label: '日均需', value: `${fmtKmSimple(dailyReq)} km` },
|
||||||
],
|
],
|
||||||
conclusion: '预估无法达标,需替换',
|
conclusion: '预估无法达标,需替换',
|
||||||
@@ -157,12 +157,10 @@ export function generateSuggestions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- replace_qualified (medium priority) ---
|
// --- replace_qualified (medium priority) ---
|
||||||
// Swap out the qualified car, swap in a car that NEEDS mileage.
|
// Every qualified vehicle gets a suggestion row so the list count matches
|
||||||
// The high-mileage customer will drive it hard → helps it reach target.
|
// `qualifiedCount`. Candidates may be empty when no inventory vehicle can
|
||||||
// Exclude candidates already at target (gap <= 0) — swapping those in is pointless.
|
// reach target at this customer — the row still surfaces for manual review.
|
||||||
for (const vehicle of qualified) {
|
for (const vehicle of qualified) {
|
||||||
if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue;
|
|
||||||
|
|
||||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||||
.filter((inv) => {
|
.filter((inv) => {
|
||||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||||
@@ -206,16 +204,13 @@ export function generateSuggestions(
|
|||||||
})
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
// Skip if no candidate can reach target — swap would be pointless
|
|
||||||
if (candidates.length === 0) continue;
|
|
||||||
|
|
||||||
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
||||||
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
||||||
const reason: ReasonBlock = {
|
const reason: ReasonBlock = {
|
||||||
lines: [
|
lines: [
|
||||||
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
||||||
{ label: '年度完成率', value: `${yearRate}%` },
|
{ label: '年度完成率', value: `${yearRate}%` },
|
||||||
{ label: '考核剩余', value: `${vehicle.daysLeft} 天` },
|
{ label: '年度考核剩余', value: `${vehicle.daysLeft} 天` },
|
||||||
{ label: '可为新车贡献', value: `约 ${fmtKmSimple(Math.round(canAddKm))} km` },
|
{ label: '可为新车贡献', value: `约 ${fmtKmSimple(Math.round(canAddKm))} km` },
|
||||||
],
|
],
|
||||||
conclusion: '已达标,建议换上未达标车辆',
|
conclusion: '已达标,建议换上未达标车辆',
|
||||||
@@ -231,8 +226,11 @@ export function generateSuggestions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove suggestions with no candidates
|
// Drop rescue_hopeless with no candidates — no actionable rescue available.
|
||||||
const filteredSuggestions = suggestions.filter((s) => s.candidates.length > 0);
|
// Keep every replace_qualified so the list count matches the qualifiedCount card.
|
||||||
|
const filteredSuggestions = suggestions.filter(
|
||||||
|
(s) => s.type === 'replace_qualified' || s.candidates.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
// Sort: high priority first
|
// Sort: high priority first
|
||||||
filteredSuggestions.sort((a, b) => {
|
filteredSuggestions.sort((a, b) => {
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import suggestionsRouter from './suggestions.js';
|
import suggestionsRouter from './suggestions.js';
|
||||||
import notifyRouter from './notify.js';
|
import notifyRouter from './notify.js';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import { canAccessScheduling } from '../../auth/types.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Module-level access guard. When auth middleware is active, `user` is set and
|
||||||
|
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
|
||||||
|
// When auth is bypassed (dev), `user` is undefined and requests pass through.
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser | undefined;
|
||||||
|
if (user && !canAccessScheduling(user.roles)) {
|
||||||
|
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
app.route('/suggestions', suggestionsRouter);
|
app.route('/suggestions', suggestionsRouter);
|
||||||
app.route('/notify', notifyRouter);
|
app.route('/notify', notifyRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -344,8 +344,8 @@ app.get('/', async (c) => {
|
|||||||
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length;
|
||||||
const recentInterventionCount = await fetchRecentInterventionCount();
|
const recentInterventionCount = await fetchRecentInterventionCount();
|
||||||
const filteredSummary: SchedulingSummary = {
|
const filteredSummary: SchedulingSummary = {
|
||||||
qualifiedCount: summary.qualifiedCount,
|
qualifiedCount: filteredQualified,
|
||||||
hopelessCount: summary.hopelessCount,
|
hopelessCount: filteredHopeless,
|
||||||
suggestionCount: masked.length,
|
suggestionCount: masked.length,
|
||||||
estimatedGain: masked.filter((s: any) =>
|
estimatedGain: masked.filter((s: any) =>
|
||||||
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
s.candidates?.some((c: any) => c.canQualifyAfterSwap)
|
||||||
|
|||||||
17
src/shared/auth/roles.ts
Normal file
17
src/shared/auth/roles.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Role constants and role-based access helpers shared between server (JWT
|
||||||
|
// issuance / API guards) and client (nav visibility / module gating).
|
||||||
|
|
||||||
|
/** 全量权限角色名 */
|
||||||
|
export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader'];
|
||||||
|
|
||||||
|
/** 部门级权限角色名 */
|
||||||
|
export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
||||||
|
|
||||||
|
/** 智能调度模块访问角色 */
|
||||||
|
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
|
||||||
|
|
||||||
|
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
|
||||||
|
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
|
||||||
|
if (!roles || roles.length === 0) return false;
|
||||||
|
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user