大改动:全部人员用ID关联(JWT带uid + audit actor_id + assessorId改用户ID + 看板按ID匹配 + 历史回填)
- JWT 载荷增加 uid;登录返回 id;前端持久化 uid 并在变更请求中携带 userId - 操作人服务端解析(优先JWT.uid,回退body.userId),审计写入 actor_id + 当时显示名 - audit_logs 增加 actor_id 列;持久化与加载带 actorId - assessments.assessorId 改存用户ID,列表/详情按ID解析显示名(assessorName) - 看板待办「分给我的」改为按 userId 匹配;发起人显示真实姓名 - 审批线指派按 salesId(用户ID) 计算 - scripts/backfill-actor-ids.sql 回填历史(旧账号名→当前用户ID),本地+生产已执行
This commit is contained in:
+21
-4
@@ -24,6 +24,15 @@ function authHeader(): Record<string, string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前登录用户 ID(用于在记录中以 ID 关联操作人;演示模式下作为 body 兜底)。 */
|
||||
export function currentUserId(): string | undefined {
|
||||
try {
|
||||
return localStorage.getItem('risk-agent-uid') ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** API 调用错误。 */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -118,6 +127,8 @@ export interface AssessmentListItem {
|
||||
readonly acceptability?: string;
|
||||
readonly createdAt: string;
|
||||
readonly assessorId: string;
|
||||
/** 发起人显示名(按 assessorId 解析)。 */
|
||||
readonly assessorName?: string;
|
||||
readonly status: WorkflowStatus;
|
||||
readonly auditLog: AuditLogEntry[];
|
||||
readonly archived?: boolean;
|
||||
@@ -439,6 +450,7 @@ export async function archiveAssessment(
|
||||
return request('POST', `/api/assessments/${id}/archive`, {
|
||||
archived,
|
||||
...(user !== undefined ? { user } : {}),
|
||||
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -473,6 +485,8 @@ export interface AssessmentDetailResponse {
|
||||
readonly expiresAt?: string | null;
|
||||
/** 审批人指派(按审批线,软约束)。 */
|
||||
readonly assignment?: { riskReviewerName: string | null; managerName: string | null } | null;
|
||||
/** 发起人显示名(按 assessorId 解析)。 */
|
||||
readonly assessorName?: string | null;
|
||||
}
|
||||
|
||||
/** 获取单条评估详情。 */
|
||||
@@ -510,6 +524,7 @@ export async function reviewAssessment(
|
||||
return request('POST', `/api/assessments/${id}/review`, {
|
||||
action,
|
||||
user,
|
||||
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
|
||||
...(comment !== undefined && comment !== '' ? { comment } : {}),
|
||||
});
|
||||
}
|
||||
@@ -525,6 +540,7 @@ export async function approveAssessment(
|
||||
return request('POST', `/api/assessments/${id}/approve`, {
|
||||
action,
|
||||
user,
|
||||
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
|
||||
...(comment !== undefined && comment !== '' ? { comment } : {}),
|
||||
...(rejectTo !== undefined ? { rejectTo } : {}),
|
||||
});
|
||||
@@ -532,12 +548,12 @@ export async function approveAssessment(
|
||||
|
||||
/** 重新提交评估。 */
|
||||
export async function resubmitAssessment(id: string, user: string): Promise<{ status: WorkflowStatus }> {
|
||||
return request('POST', `/api/assessments/${id}/resubmit`, { user });
|
||||
return request('POST', `/api/assessments/${id}/resubmit`, { user, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) });
|
||||
}
|
||||
|
||||
/** 申报:将草稿评估报送风控审核。 */
|
||||
export async function submitAssessment(id: string, user: string): Promise<{ status: WorkflowStatus }> {
|
||||
return request('POST', `/api/assessments/${id}/submit`, { user });
|
||||
return request('POST', `/api/assessments/${id}/submit`, { user, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) });
|
||||
}
|
||||
|
||||
/** 应用预测准确度校准(调整目标净利率基准,管理层)。 */
|
||||
@@ -550,7 +566,7 @@ export async function submitRedlineVerdict(
|
||||
id: string,
|
||||
params: { redlineId: string; status: '命中' | '未命中'; note?: string; user: string; role?: string; title?: string },
|
||||
): Promise<{ redlineId: string; status: string; acceptability: string }> {
|
||||
return request('POST', `/api/assessments/${id}/redline-verdict`, params);
|
||||
return request('POST', `/api/assessments/${id}/redline-verdict`, { ...params, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) });
|
||||
}
|
||||
|
||||
/** 管理层对「已通过」或「已放弃」项目直接调整工作流状态(留痕)。 */
|
||||
@@ -563,6 +579,7 @@ export async function overrideAssessmentStatus(
|
||||
return request('POST', `/api/assessments/${id}/override`, {
|
||||
status,
|
||||
user,
|
||||
...(currentUserId() !== undefined ? { userId: currentUserId() } : {}),
|
||||
...(comment !== undefined && comment !== '' ? { comment } : {}),
|
||||
});
|
||||
}
|
||||
@@ -987,7 +1004,7 @@ export async function updateAssessment(
|
||||
const res = await fetch(`${API_BASE}/api/assessments/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify(data),
|
||||
body: JSON.stringify({ ...data, ...(currentUserId() !== undefined ? { userId: currentUserId() } : {}) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||
|
||||
@@ -159,7 +159,7 @@ export function Dashboard(): JSX.Element {
|
||||
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
|
||||
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
|
||||
if (role === '商务/销售') {
|
||||
listDrafts(user?.username ?? undefined).then(setDrafts).catch(() => setDrafts([]));
|
||||
listDrafts(user?.id ?? undefined).then(setDrafts).catch(() => setDrafts([]));
|
||||
} else {
|
||||
setDrafts([]);
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export function Dashboard(): JSX.Element {
|
||||
<span style={{ fontWeight: 700, color: colorVar('color.text.primary'), whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{display}
|
||||
</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>发起人:{r.assessorId}</span>
|
||||
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>发起人:{r.assessorName ?? r.assessorId}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -517,16 +517,16 @@ export function Dashboard(): JSX.Element {
|
||||
)}
|
||||
|
||||
{todoItems.length > 0 && (() => {
|
||||
const myName = user?.username;
|
||||
const myId = user?.id;
|
||||
const assignedToMe = (id: string): boolean => {
|
||||
const a = assignments[id];
|
||||
if (a === undefined) return false;
|
||||
return role === '风控' ? a.riskReviewerName === myName : role === '管理层' ? a.managerName === myName : false;
|
||||
return role === '风控' ? a.riskReviewerId === myId : role === '管理层' ? a.managerId === myId : false;
|
||||
};
|
||||
const isAssigned = (id: string): boolean => {
|
||||
const a = assignments[id];
|
||||
if (a === undefined) return false;
|
||||
return role === '风控' ? a.riskReviewerName !== null : role === '管理层' ? a.managerName !== null : false;
|
||||
return role === '风控' ? a.riskReviewerId !== null : role === '管理层' ? a.managerId !== null : false;
|
||||
};
|
||||
// 软约束:默认只看分给我的;未指派的也展示(避免遗漏)。
|
||||
const shown = onlyMine ? todoItems.filter((t) => assignedToMe(t.id) || !isAssigned(t.id)) : todoItems;
|
||||
@@ -536,8 +536,9 @@ export function Dashboard(): JSX.Element {
|
||||
render: (r) => {
|
||||
const a = assignments[r.id];
|
||||
const name = a !== undefined ? (role === '管理层' ? a.managerName : a.riskReviewerName) : null;
|
||||
const aid = a !== undefined ? (role === '管理层' ? a.managerId : a.riskReviewerId) : null;
|
||||
if (name === null || name === undefined) return <span style={{ color: colorVar('color.text.secondary') }}>未指派</span>;
|
||||
const mine = name === myName;
|
||||
const mine = aid === myId;
|
||||
return <span style={{ ...typographyStyle('caption'), fontWeight: 600, color: mine ? '#15803D' : colorVar('color.text.primary') }}>{name}{mine ? '(我)' : ''}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -340,7 +340,7 @@ export function NewAssessment(): JSX.Element {
|
||||
const timer = setTimeout(() => {
|
||||
void saveDraft({
|
||||
id: draftId,
|
||||
assessorId: user?.username ?? null,
|
||||
assessorId: user?.id ?? null,
|
||||
sourceAssessmentId,
|
||||
projectName: projectName.trim() !== '' ? projectName : null,
|
||||
form: buildSnapshot(),
|
||||
@@ -369,7 +369,7 @@ export function NewAssessment(): JSX.Element {
|
||||
try {
|
||||
await saveDraft({
|
||||
id: draftId,
|
||||
assessorId: user?.username ?? null,
|
||||
assessorId: user?.id ?? null,
|
||||
sourceAssessmentId,
|
||||
projectName: projectName.trim() !== '' ? projectName : null,
|
||||
form: buildSnapshot(),
|
||||
@@ -700,7 +700,7 @@ export function NewAssessment(): JSX.Element {
|
||||
...(effectiveEditId !== null ? { assessmentId: effectiveEditId, useLlm: false } : {}),
|
||||
...(effectiveEditId !== null && editSavedAt !== null ? { expectedSavedAt: editSavedAt } : {}),
|
||||
...(Number(clientTotalHeadcount) > 0 ? { clientTotalHeadcount: Number(clientTotalHeadcount) } : {}),
|
||||
...(user?.username !== undefined ? { assessorId: user.username } : {}),
|
||||
...(user?.id !== undefined ? { assessorId: user.id } : {}),
|
||||
knownData: Object.entries(answers)
|
||||
.filter((e): e is [string, number] => typeof e[1] === 'number')
|
||||
.map(([k, v]) => [k, v]),
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TEST_ACCOUNTS: readonly {
|
||||
/** 认证状态。 */
|
||||
export interface AuthState {
|
||||
readonly isAuthenticated: boolean;
|
||||
readonly user: { username: string; role: AuthRole; displayName?: string } | null;
|
||||
readonly user: { id?: string; username: string; role: AuthRole; displayName?: string } | null;
|
||||
readonly error: string | null;
|
||||
|
||||
login(username: string, password: string): Promise<boolean>;
|
||||
@@ -80,10 +80,12 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
return false;
|
||||
}
|
||||
const role = d.role as AuthRole;
|
||||
const user = { username: String(d.username ?? username), role, ...(d.displayName ? { displayName: String(d.displayName) } : {}) };
|
||||
const user = { ...(d.id ? { id: String(d.id) } : {}), username: String(d.username ?? username), role, ...(d.displayName ? { displayName: String(d.displayName) } : {}) };
|
||||
saveToStorage(user);
|
||||
if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token);
|
||||
else localStorage.removeItem('risk-agent-token');
|
||||
if (d.id) localStorage.setItem('risk-agent-uid', String(d.id));
|
||||
else localStorage.removeItem('risk-agent-uid');
|
||||
set({ isAuthenticated: true, user, error: null });
|
||||
return true;
|
||||
} catch {
|
||||
@@ -94,7 +96,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
logout: () => {
|
||||
saveToStorage(null);
|
||||
try { localStorage.removeItem('risk-agent-token'); } catch { /* ignore */ }
|
||||
try { localStorage.removeItem('risk-agent-token'); localStorage.removeItem('risk-agent-uid'); } catch { /* ignore */ }
|
||||
set({ isAuthenticated: false, user: null, error: null });
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user