大改动:全部人员用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:
freedakgmail
2026-06-13 19:18:17 +08:00
parent e86e60208f
commit 5afe021b56
10 changed files with 194 additions and 47 deletions
+21 -4
View File
@@ -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 };
+7 -6
View File
@@ -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>;
},
};
+3 -3
View File
@@ -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]),
+5 -3
View File
@@ -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 });
},