Compare commits

...

6 Commits

Author SHA1 Message Date
kkfluous
6b75437423 Merge branch 'main' into demo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 解决 3 处冲突,保留 demo 的演示行为:
  - AuthProvider: 无 jumpToken 时放行
  - auth middleware: BYPASS_AUTH=true
  - Shell: DemoModeProvider enabled=true
- 引入 main 上的智能调度模块等改动
2026-04-24 10:37:54 +08:00
kkfluous
a472e543ce refactor(scheduling): gate access strictly on BI-SCHEDULE-OPT role
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Remove the implicit fallback that granted scheduling access to any
FULL_ACCESS role (所有权限 / 数智中心 / BI-Leader). Access now requires
an explicit BI-SCHEDULE-OPT assignment, so the module scope is managed
purely via role assignment rather than piggy-backing on admin roles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:50:48 +08:00
kkfluous
0c258dd1a2 fix(docker): copy src/shared into runtime image for server imports
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
src/server/auth/types.ts imports runtime values (role constants,
canAccessScheduling helper) from src/shared/auth/roles.ts — without
the shared folder in the final stage the server crashes with
ERR_MODULE_NOT_FOUND. Existing shared/scheduling imports survived
only because they were type-only and elided at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:44:49 +08:00
kkfluous
200172f0af feat(scheduling): role-based access + align list count with qualifiedCount
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Gate 智能调度 module on BI-SCHEDULE-OPT role (or full-access roles)
  via shared canAccessScheduling helper, replacing hardcoded userId allowlist
- Thread roles[] through JWT payload → middleware → frontend nav
- Add router guard that 403s non-authorized users on /api/scheduling/*
- Emit replace_qualified suggestion for every qualified vehicle so list
  count matches the 已完成考核目标 card; recalc qualifiedCount /
  hopelessCount post-permission-filter for card↔list consistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:42:21 +08:00
kkfluous
a954fb90f6 refactor(scheduling): 考核剩余 → 年度考核剩余
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 11:04:08 +08:00
kkfluous
cf8f7cf969 feat(demo): 演示模式脱敏 + 临时跳过认证
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 Blur 组件与 DemoModeProvider,全局包裹 Shell
- 资产/里程模块的客户名、业务经理、车牌、VIN 等敏感字段使用 <Blur> 包裹
- 前端 AuthProvider 无 jumpToken 时放行
- 后端 authMiddleware 开头直接 next(),跳过所有认证

仅用于演示分支,勿合并至 main。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:40:58 +08:00
13 changed files with 72 additions and 31 deletions

View File

@@ -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

View File

@@ -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 (

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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 }}>

View File

@@ -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">

View File

@@ -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' });

View File

@@ -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();

View File

@@ -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';

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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
View 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));
}