feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
This commit is contained in:
+135
-36
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { AuthContext, type AuthState } from './useAuth';
|
||||
import { setTokenGetter } from './api-client';
|
||||
import { AuthContext, type AuthState, type AuthUser } from './useAuth';
|
||||
import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client';
|
||||
|
||||
const AUTH_API = '/api/auth';
|
||||
|
||||
@@ -13,43 +13,147 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const authStarted = useRef(false);
|
||||
const previewTokenRef = useRef<string | null>(null);
|
||||
const actorRef = useRef<AuthUser | null>(null);
|
||||
const transitionRef = useRef(0);
|
||||
const [identityVersion, setIdentityVersion] = useState(0);
|
||||
const [preview, setPreview] = useState<{ user: AuthUser; expiresAt: string } | null>(null);
|
||||
const [canPreview, setCanPreview] = useState(false);
|
||||
const [previewNotice, setPreviewNotice] = useState<string | null>(null);
|
||||
|
||||
function resetPreview(message: string | null = null) {
|
||||
transitionRef.current += 1;
|
||||
previewTokenRef.current = null;
|
||||
sessionStorage.removeItem('bi_preview_token');
|
||||
invalidateAuthenticatedRequests();
|
||||
setIdentityVersion(v => v + 1);
|
||||
setPreview(null);
|
||||
setPreviewNotice(message);
|
||||
}
|
||||
|
||||
async function loadPreviewContext(user: AuthUser | null) {
|
||||
actorRef.current = user;
|
||||
try {
|
||||
const context = await fetchPreviewControl<{ canPreview: boolean; actor: AuthUser }>('context');
|
||||
actorRef.current = context.actor;
|
||||
setCanPreview(context.canPreview);
|
||||
if (context.canPreview) {
|
||||
const savedPreview = sessionStorage.getItem('bi_preview_token');
|
||||
if (savedPreview) {
|
||||
previewTokenRef.current = savedPreview;
|
||||
try {
|
||||
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
|
||||
setPreview(current);
|
||||
} catch {
|
||||
resetPreview('上次权限视角已失效,已返回本人视角。');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resetPreview();
|
||||
}
|
||||
} catch {
|
||||
resetPreview();
|
||||
setCanPreview(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishAuthentication(user: AuthUser | null) {
|
||||
await loadPreviewContext(user);
|
||||
if (!tokenRef.current) return;
|
||||
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
|
||||
}
|
||||
|
||||
async function startPreview(userId: string) {
|
||||
const transition = ++transitionRef.current;
|
||||
const next = await fetchPreviewControl<{ previewToken: string; user: AuthUser; expiresAt: string }>('start', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (transition !== transitionRef.current) return;
|
||||
invalidateAuthenticatedRequests();
|
||||
previewTokenRef.current = next.previewToken;
|
||||
sessionStorage.setItem('bi_preview_token', next.previewToken);
|
||||
setPreview({ user: next.user, expiresAt: next.expiresAt });
|
||||
setIdentityVersion(v => v + 1);
|
||||
setPreviewNotice(null);
|
||||
}
|
||||
|
||||
async function stopPreview() {
|
||||
const stopping = fetchPreviewControl('stop', { method: 'POST' }, true);
|
||||
resetPreview();
|
||||
await stopping.catch(() => { setPreviewNotice('已退出本地预览;服务端凭证将自动到期。'); });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) return;
|
||||
const token = previewTokenRef.current;
|
||||
const validate = async () => {
|
||||
try {
|
||||
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
|
||||
if (token !== previewTokenRef.current) return;
|
||||
if (JSON.stringify(current.user) !== JSON.stringify(preview.user)) {
|
||||
invalidateAuthenticatedRequests();
|
||||
setPreview(current);
|
||||
setIdentityVersion(v => v + 1);
|
||||
}
|
||||
} catch {
|
||||
if (token === previewTokenRef.current) resetPreview('权限视角验证失败,已返回本人视角,请重新选择。');
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(validate, 60_000);
|
||||
const expiry = window.setTimeout(() => resetPreview('权限视角已到期,已返回本人视角。'), Math.max(0, Date.parse(preview.expiresAt) - Date.now()));
|
||||
window.addEventListener('focus', validate);
|
||||
return () => { clearInterval(timer); clearTimeout(expiry); window.removeEventListener('focus', validate); };
|
||||
}, [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
// 设置全局 token getter
|
||||
setTokenGetter(() => tokenRef.current);
|
||||
|
||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||
if (authStarted.current) return;
|
||||
authStarted.current = true;
|
||||
setPreviewTokenGetter(() => previewTokenRef.current);
|
||||
|
||||
// 监听 401 事件
|
||||
const onUnauthorized = () => {
|
||||
resetPreview();
|
||||
setCanPreview(false);
|
||||
tokenRef.current = null;
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
actorRef.current = null;
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||
|
||||
authenticate();
|
||||
|
||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
const onPreviewEnded = (event: Event) => {
|
||||
if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false);
|
||||
resetPreview('预览权限已失效,已返回本人视角。');
|
||||
};
|
||||
const onReadOnly = () => setPreviewNotice('权限视角仅供查看数据,请退出预览后进行修改操作。');
|
||||
window.addEventListener('auth:preview-ended', onPreviewEnded);
|
||||
window.addEventListener('auth:preview-readonly', onReadOnly);
|
||||
// StrictMode 重新安装监听,但一次性 jumpToken 仅交换一次。
|
||||
if (!authStarted.current) { authStarted.current = true; void authenticate(); }
|
||||
return () => {
|
||||
window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
window.removeEventListener('auth:preview-ended', onPreviewEnded);
|
||||
window.removeEventListener('auth:preview-readonly', onReadOnly);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function authenticate() {
|
||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const devUser: AuthUser = {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
};
|
||||
// 预览授权仍由后端独立开发开关决定,不使用真实用户令牌。
|
||||
tokenRef.current = null;
|
||||
await loadPreviewContext(devUser);
|
||||
actorRef.current ||= devUser;
|
||||
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,22 +161,20 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||
if (savedToken) {
|
||||
tokenRef.current = savedToken;
|
||||
// 验证 token 是否仍然有效(尝试请求 health)
|
||||
// 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。
|
||||
try {
|
||||
const res = await fetch('/api/health', {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedUser = sessionStorage.getItem('bi_user');
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: savedUser ? JSON.parse(savedUser) : null,
|
||||
error: null,
|
||||
});
|
||||
const verifiedUser: AuthUser = await res.json();
|
||||
if (!verifiedUser.userId) throw new Error('无效的用户身份');
|
||||
await finishAuthentication(verifiedUser);
|
||||
return;
|
||||
}
|
||||
} catch { /* token 无效,继续流程 */ }
|
||||
tokenRef.current = null;
|
||||
resetPreview();
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
}
|
||||
@@ -108,19 +210,16 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
: `${window.location.pathname}${window.location.hash}`;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: data.user,
|
||||
error: null,
|
||||
});
|
||||
await finishAuthentication(data.user);
|
||||
} catch (e) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={state}>
|
||||
<AuthContext.Provider value={{ ...state, actor: actorRef.current || state.user,
|
||||
user: preview?.user || state.user, preview, canPreview, identityVersion, previewNotice,
|
||||
startPreview, stopPreview, clearPreviewNotice: () => setPreviewNotice(null) }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.permission-preview-banner{position:fixed;inset:0 0 auto;z-index:200;display:flex;align-items:center;gap:12px;min-height:56px;padding:9px 20px;background:#eff6ff;border-bottom:1px solid #adc8f4;color:#1e40af;font-size:12px;box-shadow:0 2px 8px #1d4ed10a}
|
||||
.permission-preview-identity{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}.permission-preview-identity strong{font-size:13px}.permission-preview-identity small{margin-left:14px;color:#64748b}
|
||||
.permission-preview-banner button{border:1px solid #b9cef1;border-radius:6px;background:#fff;padding:6px 10px;white-space:nowrap}.permission-preview-banner button:hover{background:#dbeafe}
|
||||
.permission-preview-active{padding-top:56px}.permission-preview-active .app-shell>nav.fixed.top-0{top:56px;height:calc(100% - 56px)}.permission-preview-active .app-shell-content>.fixed.top-0{top:56px}
|
||||
.permission-preview-notice{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:210;display:flex;align-items:center;gap:14px;padding:10px 14px;border:1px solid #f5d79e;border-radius:8px;background:#fff9ed;color:#92400e;font-size:12px;box-shadow:0 3px 12px #0f172a15;max-width:90vw}.permission-preview-notice button{flex-shrink:0}
|
||||
.permission-preview-dialog{position:fixed;inset:0;margin:auto;width:min(700px,calc(100vw - 32px));max-height:calc(100dvh - 48px);padding:0;border:1px solid #d7e1ee;border-radius:14px;background:#fff;color:#334155;box-shadow:0 24px 90px #0f172a35;font-size:12px}
|
||||
.permission-preview-dialog header{display:flex;justify-content:space-between;align-items:flex-start;padding:20px 22px 16px;border-bottom:1px solid #e1e8f0}.permission-preview-dialog h2{display:flex;align-items:center;gap:8px;margin:0;font-size:16px;font-weight:700;color:#1e293b}.permission-preview-dialog header p{margin:6px 0 0;color:#64748b}.permission-preview-dialog header button{padding:3px;color:#64748b}
|
||||
.permission-preview-body{padding:18px 22px;overflow:auto;min-height:0}.permission-preview-guide{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.permission-preview-guide>div{padding:12px;border:1px solid #dce6f3;border-radius:8px;background:#f8fafc}.permission-preview-guide strong{display:block;color:#1e40af;font-size:12px}.permission-preview-guide code{display:block;margin-top:5px;font:11px ui-monospace,monospace;color:#64748b}.permission-preview-guide p{margin:7px 0 0;line-height:1.6;color:#64748b}.permission-preview-hint{margin:12px 0 16px;color:#64748b;font-size:11px;line-height:1.7}
|
||||
.permission-preview-search{display:flex;gap:10px;margin-bottom:12px}.permission-preview-search label{display:flex;align-items:center;gap:8px;flex:1;border:1px solid #ced9e8;border-radius:7px;padding:0 10px;color:#94a3b8}.permission-preview-search input{width:100%;min-width:0;border:0;outline:0;padding:10px 0;color:#334155;background:transparent}.permission-preview-search label:focus-within{outline:2px solid #93b5ff;outline-offset:1px}.permission-preview-search select{border:1px solid #ced9e8;border-radius:7px;background:white;padding:8px;color:#475569}
|
||||
.permission-preview-users{min-height:140px;border:1px solid #dce4ef;border-radius:8px;overflow:hidden}.permission-preview-users>label{display:flex;align-items:center;gap:10px;padding:11px 13px;border-bottom:1px solid #e5ebf3;cursor:pointer}.permission-preview-users>label:last-child{border-bottom:0}.permission-preview-users>label:hover{background:#f8faff}.permission-preview-users>label.is-selected{background:#eff6ff}.permission-preview-users input{accent-color:#2563eb}.permission-preview-users label>div{min-width:0;flex:1}.permission-preview-users strong{display:block;font-size:13px;color:#334155}.permission-preview-users span{display:block;font-size:11px;color:#64748b;margin-top:3px;overflow-wrap:anywhere}.permission-preview-users small{font-weight:400;font-size:10px;color:#2563eb}.permission-preview-users em{font-style:normal;padding:4px 7px;border-radius:5px;background:#f1f5f9;color:#475569;white-space:nowrap;font-size:11px}.permission-preview-empty{padding:35px 10px;text-align:center;color:#94a3b8}
|
||||
.permission-preview-pages{display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:12px;color:#64748b}.permission-preview-pages button{border:1px solid #dce4ef;padding:5px 9px;border-radius:5px}.permission-preview-dialog button:disabled{opacity:.45;cursor:not-allowed}.permission-preview-error{padding:9px 11px;margin:10px 0;background:#fff1f2;color:#be123c;border-radius:6px;display:flex;justify-content:space-between;gap:10px}.permission-preview-error button{text-decoration:underline}
|
||||
.permission-preview-dialog footer{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 22px;border-top:1px solid #dce4ef;background:#f8fafc}.permission-preview-dialog footer strong{display:block;color:#334155;font-size:12px}.permission-preview-dialog footer span{display:block;color:#64748b;font-size:11px;margin-top:3px}.permission-preview-primary{padding:9px 13px;border-radius:7px;background:#2563eb;color:#fff;white-space:nowrap}.permission-preview-dialog button:focus-visible,.permission-preview-banner button:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
|
||||
@media(max-width:640px){.permission-preview-banner{gap:7px;padding:8px 10px;min-height:76px}.permission-preview-identity{display:block}.permission-preview-identity>span{display:block;margin-top:4px;font-size:10px}.permission-preview-identity small{display:block;margin:2px 0 0}.permission-preview-banner button{padding:6px;font-size:11px}.permission-preview-active{padding-top:76px}.permission-preview-notice{top:84px}.permission-preview-dialog{width:calc(100vw - 16px);max-height:calc(100dvh - 20px)}.permission-preview-body{padding:14px}.permission-preview-dialog header,.permission-preview-dialog footer{padding:14px}.permission-preview-guide{gap:6px}.permission-preview-guide>div{padding:8px}.permission-preview-guide code{font-size:9px}.permission-preview-guide p{font-size:10px}.permission-preview-search{gap:6px}.permission-preview-search select{max-width:116px}}
|
||||
|
||||
.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:56px}
|
||||
@media(max-width:640px){.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:76px}}
|
||||
.permission-preview-active .asset-analysis-dialog{min-height:0;max-height:calc(100dvh - 104px)}
|
||||
.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 88px)}
|
||||
@media(max-width:640px){.permission-preview-active .asset-analysis-dialog{max-height:calc(100dvh - 124px)}.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 108px)}}
|
||||
.permission-preview-page{min-height:100dvh;padding:32px 20px;background:#f4f7fb;display:flex;align-items:flex-start;justify-content:center}
|
||||
.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 56px)}
|
||||
.permission-preview-page .permission-preview-page-panel{position:static;inset:auto;display:flex;flex-direction:column;width:min(820px,100%);max-height:none;margin:0;border-color:#dce5f0;box-shadow:0 10px 35px #33415508}
|
||||
.permission-preview-page-panel .permission-preview-body{overflow:visible}
|
||||
.permission-preview-dialog header .permission-preview-back{display:inline-flex;align-items:center;justify-content:center;gap:7px;flex-shrink:0;min-height:44px;padding:10px 16px;border:1px solid #ced9e8;border-radius:8px;background:#f8fafc;font-size:14px;font-weight:600;color:#475569;white-space:nowrap;cursor:pointer;transition:background .15s,border-color .15s}.permission-preview-dialog header .permission-preview-back:hover:not(:disabled){background:#eff6ff;border-color:#adc8f4;color:#1d4ed8}
|
||||
.permission-preview-denied{width:min(500px,100%);padding:36px;background:white;border:1px solid #dce5f0;border-radius:12px;text-align:center;color:#64748b}.permission-preview-denied>svg{margin:auto;color:#94a3b8}.permission-preview-denied h1{font-size:18px;color:#334155;margin:16px 0 8px}.permission-preview-denied p{font-size:13px}.permission-preview-denied a{display:inline-block;margin-top:20px;font-size:12px;color:#2563eb}
|
||||
@media(max-width:640px){.permission-preview-page{padding:16px 10px}.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 76px)}}
|
||||
.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 64px)}
|
||||
.permission-preview-page-panel .permission-preview-body{overflow:auto}
|
||||
.permission-preview-page-panel>header,.permission-preview-page-panel>footer{flex-shrink:0}
|
||||
.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 120px)}
|
||||
@media(max-width:640px){.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 32px)}.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 108px)}}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ArrowLeft, Eye, Search, ShieldCheck, X } from 'lucide-react';
|
||||
import { fetchPreviewControl } from './api-client';
|
||||
import { useAuth, type AuthUser } from './useAuth';
|
||||
import './PermissionPreview.css';
|
||||
|
||||
const scopes = [
|
||||
{ level: 'full', title: '全量数据', role: 'BI-Leader', detail: '查看全量业务数据。' },
|
||||
{ level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '查看当前用户所在部门的数据。' },
|
||||
{ level: 'personal', title: '本人数据', role: '其他角色默认', detail: '查看本人负责的车辆。' },
|
||||
];
|
||||
export function permissionScope(user: AuthUser) {
|
||||
return scopes.find(s => s.level === user.permissionLevel)?.title || '本人数据';
|
||||
}
|
||||
export default function PermissionPreview({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, actor, preview, previewNotice, clearPreviewNotice, stopPreview } = useAuth();
|
||||
return <div className={preview ? 'permission-preview-active' : undefined}>
|
||||
{isAuthenticated && preview && <aside className="permission-preview-banner" aria-label="当前权限视角">
|
||||
<Eye size={17} /><div className="permission-preview-identity"><strong>只读预览:{preview.user.userName}</strong><span>{preview.user.depName || '未分配部门'} · {permissionScope(preview.user)}<small>登录人:{actor?.userName}</small></span></div>
|
||||
<button onClick={() => window.location.assign('/who')}>切换人员</button><button onClick={() => void stopPreview?.()}>退出预览</button>
|
||||
</aside>}
|
||||
{previewNotice && <div className="permission-preview-notice" role="status">{previewNotice}<button onClick={clearPreviewNotice} aria-label="关闭权限提示"><X size={14} /></button></div>}
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
export function PermissionPreviewPage() {
|
||||
const { canPreview } = useAuth();
|
||||
return <main className="permission-preview-page">
|
||||
{canPreview ? <PreviewPicker onClose={() => window.location.assign('/asset#assets')} /> : <section className="permission-preview-denied"><ShieldCheck size={28} /><h1>无权限访问</h1><p>当前账号未获授权使用权限视角。</p><a href="/asset#assets">返回 BI</a></section>}
|
||||
</main>;
|
||||
}
|
||||
function PreviewPicker({ onClose }: { onClose: () => void }) {
|
||||
const { startPreview, preview } = useAuth();
|
||||
const [query, setQuery] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [users, setUsers] = useState<AuthUser[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [selected, setSelected] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [retry, setRetry] = useState(0);
|
||||
const hasQuery = query.trim().length > 0;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError(''); setUsers([]); setSelected(null); setHasMore(false);
|
||||
if (!query.trim()) { setLoading(false); return; }
|
||||
setLoading(true);
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query.trim(), page: String(page) });
|
||||
if (level) params.set('level', level);
|
||||
const result = await fetchPreviewControl<{ users: AuthUser[]; page: number; hasMore: boolean }>(`users?${params}`, { signal: controller.signal });
|
||||
if (!controller.signal.aborted) { setUsers(result.users); setHasMore(result.hasMore); }
|
||||
} catch (e) {
|
||||
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败');
|
||||
} finally { if (!controller.signal.aborted) setLoading(false); }
|
||||
}, 250);
|
||||
return () => { clearTimeout(timer); controller.abort(); };
|
||||
}, [query, level, page, retry]);
|
||||
async function enter() {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true); setError('');
|
||||
try { await startPreview?.(selected.userId); onClose(); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : '切换失败,请重试'); setBusy(false); }
|
||||
}
|
||||
return <section className="permission-preview-dialog permission-preview-page-panel" aria-labelledby="permission-preview-title">
|
||||
<header><div><h2 id="permission-preview-title"><ShieldCheck size={19} />权限视角</h2><p>选择人员,查看其实际页面权限与数据范围。</p></div><button disabled={busy} className="permission-preview-back" onClick={onClose}><ArrowLeft size={18} aria-hidden="true" />返回 BI</button></header>
|
||||
<div className="permission-preview-body">
|
||||
<div className="permission-preview-guide" aria-label="三类数据权限说明">{scopes.map(scope => <div key={scope.level}><strong>{scope.title}</strong><code>{scope.role}</code><p>{scope.detail}</p></div>)}</div>
|
||||
<p className="permission-preview-hint">菜单和数据随目标用户权限变化;共享汇总仍按页面注明的公共口径展示。预览期间仅可查看,不能修改业务数据。</p>
|
||||
<div className="permission-preview-search"><label><Search size={16} /><input autoFocus type="search" value={query} disabled={busy} onChange={e => { setQuery(e.target.value); setPage(1); }} placeholder="搜索中文姓名、用户名或部门" aria-label="搜索人员" /></label><select aria-label="数据范围筛选" value={level} disabled={busy} onChange={e => { setLevel(e.target.value); setPage(1); }}><option value="">全部数据范围</option>{scopes.map(s => <option key={s.level} value={s.level}>{s.title}</option>)}</select></div>
|
||||
{error && <div className="permission-preview-error" role="alert">{error}<button disabled={busy} onClick={() => setRetry(v => v + 1)}>重试</button></div>}
|
||||
{!hasQuery ? <p className="permission-preview-empty">输入中文姓名、用户名或部门关键词,模糊搜索人员。</p> : <div className="permission-preview-users" role="radiogroup" aria-label="选择预览用户" aria-busy={loading}>
|
||||
{loading ? <p className="permission-preview-empty">正在加载人员…</p> : !users.length ? <p className="permission-preview-empty">未找到匹配人员,请调整搜索条件。</p> : users.map(user => <label key={user.userId} className={selected?.userId === user.userId ? 'is-selected' : ''}>
|
||||
<input type="radio" name="preview-user" value={user.userId} checked={selected?.userId === user.userId} disabled={busy} onChange={() => setSelected(user)} />
|
||||
<div><strong>{user.userName} {preview?.user.userId === user.userId && <small>当前视角</small>}</strong><span>{user.loginName || user.userId}</span></div><em>{permissionScope(user)}</em>
|
||||
</label>)}
|
||||
</div>}
|
||||
{hasQuery && (hasMore || page > 1) && <div className="permission-preview-pages"><button disabled={page === 1 || loading || busy} onClick={() => setPage(v => v - 1)}>上一页</button><span>第 {page} 页</span><button disabled={!hasMore || loading || busy} onClick={() => setPage(v => v + 1)}>下一页</button></div>}
|
||||
</div>
|
||||
<footer><div>{selected ? <><strong>即将进入:{selected.userName}</strong><span>{permissionScope(selected)}</span></> : <span>请选择一位用户开始预览</span>}</div><button className="permission-preview-primary" disabled={!selected || busy || loading} onClick={() => void enter()}>{busy ? '正在切换…' : '进入该用户视角'}</button></footer>
|
||||
</section>;
|
||||
}
|
||||
+49
-17
@@ -1,27 +1,59 @@
|
||||
/** 全局认证 fetch 客户端 */
|
||||
|
||||
/** 登录身份和预览身份分别传递;切换视角会取消旧视角的在途请求。 */
|
||||
let tokenGetter: () => string | null = () => null;
|
||||
|
||||
export function setTokenGetter(fn: () => string | null) {
|
||||
tokenGetter = fn;
|
||||
let previewTokenGetter: () => string | null = () => null;
|
||||
let identityAbort = new AbortController();
|
||||
let identityVersion = 0;
|
||||
export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; }
|
||||
export function setPreviewTokenGetter(fn: () => string | null) { previewTokenGetter = fn; }
|
||||
export function invalidateAuthenticatedRequests() {
|
||||
identityVersion += 1;
|
||||
identityAbort.abort();
|
||||
identityAbort = new AbortController();
|
||||
}
|
||||
const expiredPreviewCodes = new Set(['PREVIEW_EXPIRED', 'PREVIEW_REVOKED', 'PREVIEW_INVALID', 'PREVIEW_TARGET_UNAVAILABLE']);
|
||||
|
||||
export async function fetchAuthenticated(url: string, options?: RequestInit): Promise<Response> {
|
||||
async function authenticatedRequest(url: string, options: RequestInit | undefined, preview: boolean, business: boolean): Promise<Response> {
|
||||
const version = identityVersion;
|
||||
const headers = new Headers(options?.headers);
|
||||
const token = tokenGetter();
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
throw new Error('Unauthorized');
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
headers.delete('X-BI-Preview');
|
||||
const previewToken = preview ? previewTokenGetter() : null;
|
||||
if (previewToken) headers.set('X-BI-Preview', previewToken);
|
||||
const signal = business
|
||||
? options?.signal ? AbortSignal.any([options.signal, identityAbort.signal]) : identityAbort.signal
|
||||
: options?.signal;
|
||||
const res = await fetch(url, { ...options, headers, signal });
|
||||
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
|
||||
if (res.status === 401 || res.status === 403 || res.status === 410) {
|
||||
const body = await res.clone().json().catch(() => ({}));
|
||||
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
|
||||
if (expiredPreviewCodes.has(body.code)) {
|
||||
// 已切换到其他人的旧响应不能结束新预览。
|
||||
if (previewToken && previewToken === previewTokenGetter()) window.dispatchEvent(new CustomEvent('auth:preview-ended', { detail: body.code }));
|
||||
throw new Error('权限视角已失效,请重新选择用户');
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (body.code === 'PREVIEW_READ_ONLY') {
|
||||
window.dispatchEvent(new CustomEvent('auth:preview-readonly'));
|
||||
throw new Error('权限视角为只读,请退出预览后操作');
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function fetchAuthenticated(url: string, options?: RequestInit): Promise<Response> {
|
||||
return authenticatedRequest(url, options, true, true);
|
||||
}
|
||||
/** 管理接口始终使用实际登录身份;仅 current/stop 附带当前预览凭证。 */
|
||||
export async function fetchPreviewControl<T>(path: string, options?: RequestInit, includePreview = false): Promise<T> {
|
||||
const res = await authenticatedRequest(`/api/permission-preview/${path}`, options, includePreview, false);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.message || '权限视角请求失败,请重试');
|
||||
return data as T;
|
||||
}
|
||||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetchAuthenticated(url, options);
|
||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
|
||||
+20
-18
@@ -1,25 +1,27 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
userName: string;
|
||||
loginName?: string;
|
||||
permissionLevel: string;
|
||||
depCode?: string;
|
||||
depName: string;
|
||||
roles?: string[];
|
||||
}
|
||||
export interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
permissionLevel: string;
|
||||
depName: string;
|
||||
roles?: string[];
|
||||
} | null;
|
||||
user: AuthUser | null;
|
||||
error: string | null;
|
||||
actor?: AuthUser | null;
|
||||
canPreview?: boolean;
|
||||
preview?: { user: AuthUser; expiresAt: string } | null;
|
||||
identityVersion?: number;
|
||||
previewNotice?: string | null;
|
||||
startPreview?: (userId: string) => Promise<void>;
|
||||
stopPreview?: () => Promise<void>;
|
||||
clearPreviewNotice?: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthState>({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
export const AuthContext = createContext<AuthState>({ isLoading: true, isAuthenticated: false, user: null, error: null });
|
||||
export function useAuth() { return useContext(AuthContext); }
|
||||
|
||||
Reference in New Issue
Block a user