初步完成ai插件对接
This commit is contained in:
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* ai-server OpenApi(MOC 插件)接入层
|
||||||
|
* 契约:《变更管理系统插件接口说明文档 V1.0》(ai-server Module/OpenApi)
|
||||||
|
* - base 走 vite 代理 /proxy-ai -> http://127.0.0.1:12333(见 vite.config.ts)
|
||||||
|
* - 鉴权:app 凭据换应用令牌(缓存 2h)→ exchange 换会员 JWT(ai_token,会话级缓存)
|
||||||
|
* - 本地联调形态:前端直连 exchange(moc-server 的登录双令牌下发链路就绪后,
|
||||||
|
* 改为登录时由 moc-server 下发 ai_token,本层仅需替换 getAiToken 来源)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** ai-server 统一响应 */
|
||||||
|
interface AiResp<T = any> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiError extends Error {
|
||||||
|
code: number;
|
||||||
|
|
||||||
|
data: any;
|
||||||
|
|
||||||
|
constructor(code: number, message: string, data?: any) {
|
||||||
|
super(message || `AI 服务错误(${code})`);
|
||||||
|
this.code = code;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = (import.meta.env.VITE_AI_BASE_URL as string) || '/proxy-ai';
|
||||||
|
const APP_ID = (import.meta.env.VITE_AI_APP_ID as string) || 'moc_jcyh';
|
||||||
|
const APP_SECRET = (import.meta.env.VITE_AI_APP_SECRET as string) || 'moc-jcyh-dev-secret';
|
||||||
|
|
||||||
|
/** exchange 用的当前 MOC 用户身份(登录成功后由调用方注入) */
|
||||||
|
export interface AiUserIdentity {
|
||||||
|
platform_user_id: string;
|
||||||
|
align_key: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity: AiUserIdentity = {
|
||||||
|
// 本地联调默认用户(文档 §8.2)
|
||||||
|
platform_user_id: '1001',
|
||||||
|
align_key: 'zhang.gong@jcyh.local',
|
||||||
|
display_name: '张工'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 登录后注入真实用户身份;用户变更会强制重新 exchange */
|
||||||
|
export function setAiUser(user: Partial<AiUserIdentity>) {
|
||||||
|
identity = { ...identity, ...user };
|
||||||
|
aiTokenCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let appTokenCache: { token: string; expireAt: number } | null = null;
|
||||||
|
let aiTokenCache: { token: string; userKey: string } | null = null;
|
||||||
|
let requestSeq = 0;
|
||||||
|
|
||||||
|
async function rawFetch<T>(path: string, init: RequestInit): Promise<AiResp<T>> {
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${BASE}${path}`, init);
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new AiError(-1, `AI 服务不可达:${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await resp.json()) as AiResp<T>;
|
||||||
|
} catch {
|
||||||
|
throw new AiError(resp.status, `AI 服务响应异常(HTTP ${resp.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAppToken(): Promise<string> {
|
||||||
|
if (appTokenCache && appTokenCache.expireAt > Date.now() + 60_000) return appTokenCache.token;
|
||||||
|
const body = await rawFetch<{ access_token: string; expires_in: number }>('/open/v1/auth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET })
|
||||||
|
});
|
||||||
|
if (body.code !== 0) throw new AiError(body.code, body.message || '应用令牌获取失败', body.data);
|
||||||
|
appTokenCache = { token: body.data.access_token, expireAt: Date.now() + (body.data.expires_in ?? 7200) * 1000 };
|
||||||
|
return appTokenCache.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAiToken(): Promise<string> {
|
||||||
|
const userKey = identity.platform_user_id || identity.align_key;
|
||||||
|
if (aiTokenCache && aiTokenCache.userKey === userKey) return aiTokenCache.token;
|
||||||
|
const cached = sessionStorage.getItem(`ai_token:${userKey}`);
|
||||||
|
if (cached) {
|
||||||
|
aiTokenCache = { token: cached, userKey };
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const appToken = await getAppToken();
|
||||||
|
const body = await rawFetch<{ token: string }>('/open/v1/auth/exchange', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
platform: 'moc',
|
||||||
|
platform_user_id: identity.platform_user_id,
|
||||||
|
align_key: identity.align_key,
|
||||||
|
display_name: identity.display_name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (body.code !== 0) throw new AiError(body.code, body.message || 'ai_token 签发失败', body.data);
|
||||||
|
aiTokenCache = { token: body.data.token, userKey };
|
||||||
|
sessionStorage.setItem(`ai_token:${userKey}`, body.data.token);
|
||||||
|
return body.data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ai_token 失效(40101)时清缓存并自动重新 exchange 重试一次 */
|
||||||
|
async function aiFetch<T = any>(path: string, payload?: unknown, method: 'GET' | 'POST' = 'POST', retried = false): Promise<T> {
|
||||||
|
const token = await getAiToken();
|
||||||
|
const body = await rawFetch<T>(path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: method === 'GET' ? undefined : JSON.stringify(payload ?? {})
|
||||||
|
});
|
||||||
|
if (body.code === 0) return body.data;
|
||||||
|
if (body.code === 40101 && !retried) {
|
||||||
|
aiTokenCache = null;
|
||||||
|
sessionStorage.removeItem(`ai_token:${identity.platform_user_id || identity.align_key}`);
|
||||||
|
return aiFetch<T>(path, payload, method, true);
|
||||||
|
}
|
||||||
|
throw new AiError(body.code, body.message || 'AI 分析失败', body.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRequestId(prefix: string): string {
|
||||||
|
requestSeq += 1;
|
||||||
|
return `moc-${Date.now()}-${requestSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 类型与默认载荷 ---------- */
|
||||||
|
|
||||||
|
export interface CompareRow {
|
||||||
|
item: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
hl?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixPayload {
|
||||||
|
size: string;
|
||||||
|
l_range?: [number, number];
|
||||||
|
s_range?: [number, number];
|
||||||
|
rr?: { min: number; level: string; name: string }[];
|
||||||
|
default_level?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LevelDim {
|
||||||
|
dim: string;
|
||||||
|
name: string;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MOC 官方风险矩阵(对齐 service_system_config: risk.matrix.official,5×5 LS) */
|
||||||
|
export const DEFAULT_MATRIX: MatrixPayload = {
|
||||||
|
size: '5x5',
|
||||||
|
l_range: [1, 5],
|
||||||
|
s_range: [1, 5],
|
||||||
|
rr: [
|
||||||
|
{ min: 15, level: 'MAJOR', name: '重大' },
|
||||||
|
{ min: 10, level: 'HIGH', name: '较大' },
|
||||||
|
{ min: 5, level: 'MID', name: '一般' }
|
||||||
|
],
|
||||||
|
default_level: 'LOW'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** MOC 等级评分维度(对齐 change.level.dims,6 维固化) */
|
||||||
|
export const DEFAULT_LEVEL_DIMS: LevelDim[] = [
|
||||||
|
{ dim: 'EQUIPMENT', name: '设备影响', max: 4 },
|
||||||
|
{ dim: 'PROCESS', name: '工艺影响', max: 4 },
|
||||||
|
{ dim: 'SAFETY', name: '安全风险', max: 4 },
|
||||||
|
{ dim: 'ENVIRONMENT', name: '环境影响', max: 4 },
|
||||||
|
{ dim: 'QUALITY', name: '质量影响', max: 4 },
|
||||||
|
{ dim: 'SCHEDULE', name: '进度与成本', max: 4 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- 变更智能分析(同步) ---------- */
|
||||||
|
|
||||||
|
export interface RecognizeResult {
|
||||||
|
rows: CompareRow[];
|
||||||
|
is_change: boolean;
|
||||||
|
same_kind_replace: boolean;
|
||||||
|
materials_suggest: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiRecognize = (content: string, deviceTags: string[] = []) =>
|
||||||
|
aiFetch<RecognizeResult>('/open/v1/moc/change/recognize', {
|
||||||
|
request_id: nextRequestId('rec'),
|
||||||
|
content,
|
||||||
|
device_tags: deviceTags
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ApplicationResult {
|
||||||
|
name: string;
|
||||||
|
main_changes: string[];
|
||||||
|
related_changes: string[];
|
||||||
|
purposes: string[];
|
||||||
|
effect: string;
|
||||||
|
change_type: string;
|
||||||
|
level_suggestion: string | null;
|
||||||
|
duration_suggestion: string | null;
|
||||||
|
materials_text: string;
|
||||||
|
update_docs: string[];
|
||||||
|
disciplines: string[];
|
||||||
|
signers: Record<string, string> | null;
|
||||||
|
risk_tools: string[];
|
||||||
|
department_opinions?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiApplication = (content: string, rows: CompareRow[]) =>
|
||||||
|
aiFetch<ApplicationResult>('/open/v1/moc/change/application', {
|
||||||
|
request_id: nextRequestId('app'),
|
||||||
|
content,
|
||||||
|
rows
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface PreRiskResult {
|
||||||
|
severity: string;
|
||||||
|
probability: string;
|
||||||
|
protection: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiRiskPreAnalysis = (content: string, rows: CompareRow[]) =>
|
||||||
|
aiFetch<PreRiskResult>('/open/v1/moc/change/risk-pre-analysis', {
|
||||||
|
request_id: nextRequestId('pre'),
|
||||||
|
content,
|
||||||
|
rows
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface TypeJudgeResult {
|
||||||
|
type: string;
|
||||||
|
confidence: number;
|
||||||
|
conclusion: string;
|
||||||
|
hit_rules: string[];
|
||||||
|
exclude_rules: string[];
|
||||||
|
knowledge_refs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiTypeJudge = (content: string, rows: CompareRow[]) =>
|
||||||
|
aiFetch<TypeJudgeResult>('/open/v1/moc/change/type-judge', {
|
||||||
|
request_id: nextRequestId('type'),
|
||||||
|
content,
|
||||||
|
rows
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface LevelScoreResult {
|
||||||
|
dims: { dim: string; name: string; score: number | null; basis: string }[];
|
||||||
|
total_score: number;
|
||||||
|
analysis_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiLevelScore = (content: string, rows: CompareRow[], levelDims: LevelDim[] = DEFAULT_LEVEL_DIMS) =>
|
||||||
|
aiFetch<LevelScoreResult>('/open/v1/moc/change/level-score', {
|
||||||
|
request_id: nextRequestId('level'),
|
||||||
|
content,
|
||||||
|
rows,
|
||||||
|
level_dims: levelDims
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RiskCheckItem {
|
||||||
|
no?: number;
|
||||||
|
group: string;
|
||||||
|
q: string;
|
||||||
|
ans: string;
|
||||||
|
risk?: string;
|
||||||
|
measure?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiRiskCheck = (content: string, rows: CompareRow[], checkTemplate?: object) =>
|
||||||
|
aiFetch<{ items: RiskCheckItem[] }>('/open/v1/moc/change/risk-check', {
|
||||||
|
request_id: nextRequestId('check'),
|
||||||
|
content,
|
||||||
|
rows,
|
||||||
|
...(checkTemplate ? { check_template: checkTemplate } : {})
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RiskRecord {
|
||||||
|
item: string;
|
||||||
|
scene: string;
|
||||||
|
l: number | null;
|
||||||
|
s: number | null;
|
||||||
|
existing: string;
|
||||||
|
suggest: string;
|
||||||
|
residual_l: number | null;
|
||||||
|
residual_s: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiRiskOrganize = (content: string, rows: CompareRow[], toolResults?: object) =>
|
||||||
|
aiFetch<{ records: RiskRecord[] }>('/open/v1/moc/risk/organize', {
|
||||||
|
request_id: nextRequestId('org'),
|
||||||
|
content,
|
||||||
|
rows,
|
||||||
|
tool_results: toolResults ?? {},
|
||||||
|
matrix_payload: DEFAULT_MATRIX
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 风险分析插件(同步,仅出 L/S) ---------- */
|
||||||
|
|
||||||
|
export interface HazopRow {
|
||||||
|
cause: string;
|
||||||
|
consequence: string;
|
||||||
|
l: number | null;
|
||||||
|
s: number | null;
|
||||||
|
safeguards: string;
|
||||||
|
suggestions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiHazopDeviation = (payload: {
|
||||||
|
node_name: string;
|
||||||
|
deviation: string;
|
||||||
|
object_name?: string;
|
||||||
|
change_context?: object;
|
||||||
|
}) =>
|
||||||
|
aiFetch<{ rows: HazopRow[] }>('/open/v1/moc/hazop/deviation-analyze', {
|
||||||
|
request_id: nextRequestId('hazop'),
|
||||||
|
matrix_payload: DEFAULT_MATRIX,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface JsaStep {
|
||||||
|
step: string;
|
||||||
|
desc: string;
|
||||||
|
rows: { hazard: string; consequence: string; l: number | null; s: number | null; safeguards: string; suggestions: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiJsaAnalyze = (payload: {
|
||||||
|
job_info: { job_name: string; job_type?: string; job_location?: string };
|
||||||
|
steps?: any[];
|
||||||
|
change_context?: object;
|
||||||
|
mode?: 'supplement' | 'full';
|
||||||
|
}) =>
|
||||||
|
aiFetch<{ steps: JsaStep[] }>('/open/v1/moc/jsa/analyze', {
|
||||||
|
request_id: nextRequestId('jsa'),
|
||||||
|
mode: 'supplement',
|
||||||
|
matrix_payload: DEFAULT_MATRIX,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SclGroup {
|
||||||
|
g: string;
|
||||||
|
rows: { item: string; std: string; consequence: string; l: number | null; s: number | null; controls: string; suggestions: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiSclAnalyze = (payload: {
|
||||||
|
device: { name: string; material_info?: string; run_params?: string };
|
||||||
|
category: string;
|
||||||
|
scl_template?: object;
|
||||||
|
change_context?: object;
|
||||||
|
}) =>
|
||||||
|
aiFetch<{ groups: SclGroup[] }>('/open/v1/moc/scl/analyze', {
|
||||||
|
request_id: nextRequestId('scl'),
|
||||||
|
matrix_payload: DEFAULT_MATRIX,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 辅助类 ---------- */
|
||||||
|
|
||||||
|
export const aiTraining = (content: string, rows: CompareRow[], riskRecords?: any[]) =>
|
||||||
|
aiFetch<{ training_text: string; suggest_posts: string[] }>('/open/v1/moc/change/training', {
|
||||||
|
request_id: nextRequestId('train'),
|
||||||
|
content,
|
||||||
|
rows,
|
||||||
|
risk_records: riskRecords ?? []
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiPssr = (payload: { content: string; change_type?: string; risk_records?: any[]; action_items?: any[] }) =>
|
||||||
|
aiFetch<{ groups: { g: string; items: { content: string; phase: string }[] }[] }>('/open/v1/moc/change/pssr', {
|
||||||
|
request_id: nextRequestId('pssr'),
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiAcceptance = (payload: { purpose?: string; effect?: string }) =>
|
||||||
|
aiFetch<{ rows: { item: string; basis: string; standard: string; method: string; owner_suggest: string }[] }>(
|
||||||
|
'/open/v1/moc/change/acceptance',
|
||||||
|
{ request_id: nextRequestId('acc'), ...payload }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiDisposalDraft = (payload: { original_summary: string; disposal_type: string; long_term_scene?: string }) =>
|
||||||
|
aiFetch<{ reason: string; risk: string; long_term_risk_points: string[] }>('/open/v1/moc/disposal/draft', {
|
||||||
|
request_id: nextRequestId('disp'),
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiGlossary = () => aiFetch<{ terms: { word: string; explanation: string }[] }>('/open/v1/moc/glossary', undefined, 'GET');
|
||||||
|
|
||||||
|
/* ---------- 异步任务 ---------- */
|
||||||
|
|
||||||
|
export interface OpenTask {
|
||||||
|
task_id: string;
|
||||||
|
task_type?: string;
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
progress: number;
|
||||||
|
current_step?: string;
|
||||||
|
steps?: { step: string; status: string }[];
|
||||||
|
error_msg?: string;
|
||||||
|
result?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiApprovalAssist = (payload: {
|
||||||
|
snapshot: object;
|
||||||
|
change_id_ref?: string;
|
||||||
|
submit_version?: number;
|
||||||
|
inflight_changes?: any[];
|
||||||
|
manual_filled?: string[];
|
||||||
|
}) =>
|
||||||
|
aiFetch<{ task_id: string; status: string; estimated_seconds: number }>('/open/v1/moc/approval/assist', {
|
||||||
|
request_id: nextRequestId('assist'),
|
||||||
|
matrix_payload: DEFAULT_MATRIX,
|
||||||
|
level_dims: DEFAULT_LEVEL_DIMS,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiKnowledgeIngest = (doc: {
|
||||||
|
name: string;
|
||||||
|
file_url?: string;
|
||||||
|
file_base64?: string;
|
||||||
|
version?: string;
|
||||||
|
category?: string;
|
||||||
|
hash?: string;
|
||||||
|
}) =>
|
||||||
|
aiFetch<{ task_id: string; status: string; estimated_seconds: number }>('/open/v1/moc/knowledge/ingest', {
|
||||||
|
request_id: nextRequestId('ingest'),
|
||||||
|
doc
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getTask = (taskId: string) => aiFetch<OpenTask>(`/open/v1/tasks/${taskId}`, undefined, 'GET');
|
||||||
|
|
||||||
|
export const getTaskResult = (taskId: string) => aiFetch<OpenTask>(`/open/v1/tasks/${taskId}/result`, undefined, 'GET');
|
||||||
|
|
||||||
|
/** 轮询任务直至完成/失败 */
|
||||||
|
export async function pollTask(
|
||||||
|
taskId: string,
|
||||||
|
opts: { intervalMs?: number; timeoutMs?: number; onProgress?: (t: OpenTask) => void } = {}
|
||||||
|
): Promise<any> {
|
||||||
|
const interval = opts.intervalMs ?? 3000;
|
||||||
|
const deadline = Date.now() + (opts.timeoutMs ?? 300_000);
|
||||||
|
for (;;) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const t = await getTask(taskId);
|
||||||
|
opts.onProgress?.(t);
|
||||||
|
if (t.status === 'completed') {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const r = await getTaskResult(taskId);
|
||||||
|
return r?.result ?? r;
|
||||||
|
}
|
||||||
|
if (t.status === 'failed') throw new AiError(50202, t.error_msg || 'AI 任务执行失败');
|
||||||
|
if (Date.now() > deadline) throw new AiError(50202, 'AI 任务等待超时,请稍后查看');
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise(r => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 健康检查(联调验证用) */
|
||||||
|
export const aiHealth = () => aiFetch<{ status: string }>('/open/v1/health', undefined, 'GET');
|
||||||
+19
-19
@@ -1,19 +1,19 @@
|
|||||||
import { request } from '../request';
|
import { request } from '../request';
|
||||||
|
|
||||||
/** 获取角色列表
|
/** 获取角色列表
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export function roleListApi() {
|
export function roleListApi() {
|
||||||
return request({ url: '/api/role' });
|
return request({ url: '/api/role' });
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 编辑角色
|
* 编辑角色
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export function editRoleApi(params: { id: number, intro: string, name: string, perms: string[] }) {
|
export function editRoleApi(params: { id: number, intro: string, name: string, perms: string[] }) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/role',
|
url: '/api/role',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: params
|
data: params
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { useLoading } from '@sa/hooks';
|
import { useLoading } from '@sa/hooks';
|
||||||
import { fetchGetUserInfo, fetchLogin } from '@/service/api';
|
import { fetchGetUserInfo, fetchLogin } from '@/service/api';
|
||||||
|
import { setAiUser } from '@/service/api/ai';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import { localStg } from '@/utils/storage';
|
import { localStg } from '@/utils/storage';
|
||||||
import { SetupStoreId } from '@/enum';
|
import { SetupStoreId } from '@/enum';
|
||||||
@@ -151,6 +152,13 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
// update store
|
// update store
|
||||||
Object.assign(userInfo, info);
|
Object.assign(userInfo, info);
|
||||||
|
|
||||||
|
// 同步 AI 插件用户身份(exchange 换 ai_token 用)
|
||||||
|
setAiUser({
|
||||||
|
platform_user_id: String(info.userId),
|
||||||
|
align_key: info.username,
|
||||||
|
display_name: info.username
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
|||||||
import { useAppStore } from '@/store/modules/app';
|
import { useAppStore } from '@/store/modules/app';
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
|
import { aiKnowledgeIngest, pollTask, AiError } from '@/service/api/ai';
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
@@ -29,8 +30,26 @@ const preview = (d:any) => {
|
|||||||
const download = (d:any) => {
|
const download = (d:any) => {
|
||||||
window.$message?.success(`演示:下载 ${d.name}`)
|
window.$message?.success(`演示:下载 ${d.name}`)
|
||||||
}
|
}
|
||||||
const call = (d:any) => {
|
// 已纳入 AI 知识库的条目(name -> true)与摄入中状态
|
||||||
window.$message?.success(`演示:已将「${d.name}」调用为当前变更的 AI 知识来源(调用记录留痕)`)
|
const ingested = ref<Record<string, boolean>>({});
|
||||||
|
const ingesting = ref<Record<string, boolean>>({});
|
||||||
|
const call = async (d:any) => {
|
||||||
|
if (ingesting.value[d.name] || ingested.value[d.name]) return;
|
||||||
|
ingesting.value[d.name] = true;
|
||||||
|
try {
|
||||||
|
const { task_id } = await aiKnowledgeIngest({ name: d.name, version: d.ver, category: d.cat });
|
||||||
|
await pollTask(task_id, {});
|
||||||
|
ingested.value[d.name] = true;
|
||||||
|
window.$message?.success(`已将「${d.name}」纳入 AI 知识库(调用记录留痕)`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof AiError) {
|
||||||
|
window.$message?.warning(`AI 知识摄入失败:${e.message},可稍后重试`);
|
||||||
|
} else {
|
||||||
|
window.$message?.warning('AI 知识摄入失败,可稍后重试');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ingesting.value[d.name] = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
interface LibDoc { name: string; ver: string; date: string }
|
interface LibDoc { name: string; ver: string; date: string }
|
||||||
interface LibFolder { label: string; docs: LibDoc[] }
|
interface LibFolder { label: string; docs: LibDoc[] }
|
||||||
@@ -210,7 +229,8 @@ const libCur = computed(() => LIB_ORGS.find((o) => o.org === libSel.value.org)?.
|
|||||||
<div class="flex shrink-0 gap-3 text-xs">
|
<div class="flex shrink-0 gap-3 text-xs">
|
||||||
<NButton type="primary" text class="hover:underline text-xs" @click="preview(d)">查看</NButton>
|
<NButton type="primary" text class="hover:underline text-xs" @click="preview(d)">查看</NButton>
|
||||||
<NButton type="primary" text class="hover:underline text-xs" @click="download(d)">下载</NButton>
|
<NButton type="primary" text class="hover:underline text-xs" @click="download(d)">下载</NButton>
|
||||||
<NButton type="primary" text class="hover:underline text-xs" @click="call(d)">调用</NButton>
|
<NTag v-if="ingested[d.name]" size="small" type="success" class="text-[10px]">已纳入 AI 知识库</NTag>
|
||||||
|
<NButton v-else type="primary" text class="hover:underline text-xs" :loading="!!ingesting[d.name]" @click="call(d)">调用</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
|
import { aiApprovalAssist, pollTask, AiError } from '@/service/api/ai';
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
@@ -69,13 +70,14 @@ const confirmClose = () => {
|
|||||||
window.$message?.success('强制合规项已确认闭环,操作已留痕');
|
window.$message?.success('强制合规项已确认闭环,操作已留痕');
|
||||||
}
|
}
|
||||||
const conflictHide = ref<boolean>(false);
|
const conflictHide = ref<boolean>(false);
|
||||||
const PARAM_DIFF = [
|
// 关键参数对比(静态示例作 fallback,AI 审批辅助完成后回填)
|
||||||
|
const paramDiff = ref([
|
||||||
{ k: "反应温度", before: "80℃", after: "90℃", over: false, note: "仍在溶剂沸点与物料分解温度的安全裕度内" },
|
{ k: "反应温度", before: "80℃", after: "90℃", over: false, note: "仍在溶剂沸点与物料分解温度的安全裕度内" },
|
||||||
{ k: "保温时间", before: "8 h", after: "4 h", over: false, note: "终点判断窗口变窄,须修订操作规程" },
|
{ k: "保温时间", before: "8 h", after: "4 h", over: false, note: "终点判断窗口变窄,须修订操作规程" },
|
||||||
{ k: "DCS 温度高报警", before: "85℃", after: "95℃", over: true, note: "超出原报警裕度,须重新整定验证" },
|
{ k: "DCS 温度高报警", before: "85℃", after: "95℃", over: true, note: "超出原报警裕度,须重新整定验证" },
|
||||||
{ k: "联锁高高报", before: "90℃", after: "100℃", over: true, note: "接近设备设计温度上限,投用前须完成联锁测试" },
|
{ k: "联锁高高报", before: "90℃", after: "100℃", over: true, note: "接近设备设计温度上限,投用前须完成联锁测试" },
|
||||||
{ k: "未知杂质含量", before: "0.05%", after: "0.12%", over: false, note: "结构未定,须纳入日常分析计划" },
|
{ k: "未知杂质含量", before: "0.05%", after: "0.12%", over: false, note: "结构未定,须纳入日常分析计划" },
|
||||||
];
|
]);
|
||||||
const aiRead = ref<boolean>(false);
|
const aiRead = ref<boolean>(false);
|
||||||
const roleName = ref<string>('张工艺');
|
const roleName = ref<string>('张工艺');
|
||||||
const suggs = ref([
|
const suggs = ref([
|
||||||
@@ -152,6 +154,109 @@ const handleReminder = (m: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// AI 审批辅助(抽屉打开时触发,异步任务轮询;失败保留静态示例,不阻塞页面)
|
||||||
|
const aiAssistLoading = ref<boolean>(false);
|
||||||
|
const aiAssistStep = ref<string>('');
|
||||||
|
const aiAssistProgress = ref<number>(0);
|
||||||
|
const aiAssistLoaded = ref<boolean>(false);
|
||||||
|
// 强制合规描述(静态示例作 fallback)
|
||||||
|
const mandText = ref<string>('本次变更涉及 DCS 联锁设定值修改,依据 AQ/T 3034《化工企业工艺安全管理实施导则》,投用前须完成联锁测试并留存记录。');
|
||||||
|
const mandItem = ref<string>('联锁测试记录须在投用前完成并上传归档');
|
||||||
|
// 冲突提示
|
||||||
|
const conflictText = ref<string>('涉及设备 R-101 关联变更 MOC-2026-0038「机封冲洗方案调整」正在审批中,请确认施工时序与隔离方案无冲突。');
|
||||||
|
// 属地影响摘要
|
||||||
|
const deptRows = ref([
|
||||||
|
{ label: '涉及单元:', text: '一车间 R-201 反应釜及配套 DCS 回路,不影响相邻单元。' },
|
||||||
|
{ label: '现行规程影响:', text: '操作规程第 4.2 节(报警处置)需同步修订,变更关闭前完成受控换版。' },
|
||||||
|
{ label: '交接班提示:', text: '投用后首个班次需向班组交代新报警值与处置卡位置,建议纳入班前会内容。' },
|
||||||
|
]);
|
||||||
|
// 参考信息(相似变更 / 历史经验)
|
||||||
|
const similarText = ref<string>('相似变更 MOC-2025-0112「酯化反应温度上调」投用后运行正常,无异常记录');
|
||||||
|
const experienceText = ref<string>('历史经验:温度类变更投用后首周超温报警占比约 38%,建议关注投用首周报警趋势');
|
||||||
|
|
||||||
|
const toTextList = (v: any): string[] => {
|
||||||
|
if (!v) return [];
|
||||||
|
if (typeof v === 'string') return [v];
|
||||||
|
if (Array.isArray(v)) return v.map((x: any) => (typeof x === 'string' ? x : x?.text ?? x?.content ?? '')).filter(Boolean);
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAiAssist = async () => {
|
||||||
|
if (aiAssistLoading.value || aiAssistLoaded.value) return;
|
||||||
|
aiAssistLoading.value = true;
|
||||||
|
aiAssistStep.value = '提交分析任务…';
|
||||||
|
aiAssistProgress.value = 0;
|
||||||
|
try {
|
||||||
|
const snapshot = {
|
||||||
|
apply_form: currentInfo.value ?? {},
|
||||||
|
compare_rows: [],
|
||||||
|
risk_records: [],
|
||||||
|
materials: []
|
||||||
|
};
|
||||||
|
const { task_id } = await aiApprovalAssist({
|
||||||
|
snapshot,
|
||||||
|
change_id_ref: currentInfo.value?.cid,
|
||||||
|
submit_version: 1
|
||||||
|
});
|
||||||
|
const result: any = await pollTask(task_id, {
|
||||||
|
onProgress: t => {
|
||||||
|
aiAssistStep.value = t.current_step || 'AI 分析中…';
|
||||||
|
aiAssistProgress.value = t.progress ?? 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const r = result?.result ?? result ?? {};
|
||||||
|
// 强制合规(mandatory 可能为字符串或数组)
|
||||||
|
const mand = toTextList(r.mandatory);
|
||||||
|
if (mand.length) {
|
||||||
|
mandText.value = mand.join(';');
|
||||||
|
mandItem.value = mand[0];
|
||||||
|
}
|
||||||
|
// 冲突提示
|
||||||
|
const conflicts = toTextList(r.conflicts);
|
||||||
|
if (conflicts.length) conflictText.value = conflicts.join(';');
|
||||||
|
// 关键参数对比
|
||||||
|
if (Array.isArray(r.param_diff) && r.param_diff.length) {
|
||||||
|
paramDiff.value = r.param_diff.map((d: any) => ({
|
||||||
|
k: d.k ?? d.item ?? d.name ?? '',
|
||||||
|
before: d.before ?? '',
|
||||||
|
after: d.after ?? '',
|
||||||
|
over: Boolean(d.over),
|
||||||
|
note: d.note ?? d.basis ?? ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 推荐改进项
|
||||||
|
const sugs = toTextList(r.suggestions);
|
||||||
|
if (sugs.length) suggs.value = sugs.map((text, i) => ({ id: i + 1, text, st: '待定' }));
|
||||||
|
// 相似变更 / 历史经验
|
||||||
|
const similar = toTextList(r.similar_changes);
|
||||||
|
if (similar.length) similarText.value = similar[0];
|
||||||
|
const exp = toTextList(r.experience);
|
||||||
|
if (exp.length) experienceText.value = exp[0];
|
||||||
|
// 属地影响摘要(字符串或数组,兼容对象形态)
|
||||||
|
if (r.dept_summary) {
|
||||||
|
const rows = toTextList(r.dept_summary).map((t: string) => {
|
||||||
|
const idx = t.indexOf(':');
|
||||||
|
return idx > 0 ? { label: `${t.slice(0, idx + 1)}`, text: t.slice(idx + 1) } : { label: '', text: t };
|
||||||
|
});
|
||||||
|
if (rows.length) deptRows.value = rows;
|
||||||
|
}
|
||||||
|
aiAssistLoaded.value = true;
|
||||||
|
window.$message?.success('AI 审批辅助分析完成,结果仅供参考,需人工确认');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof AiError) {
|
||||||
|
window.$message?.warning(`AI 分析失败:${e.message},展示静态示例,可人工判断`);
|
||||||
|
} else {
|
||||||
|
window.$message?.warning('AI 分析失败,展示静态示例,可人工判断');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
aiAssistLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(aiDrawer, v => {
|
||||||
|
if (v) runAiAssist();
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if(route.query && route.query.params){
|
if(route.query && route.query.params){
|
||||||
currentInfo.value = JSON.parse(route.query.params as string);
|
currentInfo.value = JSON.parse(route.query.params as string);
|
||||||
@@ -1038,6 +1143,14 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<!-- AI 分析进度(异步任务轮询) -->
|
||||||
|
<div v-if="aiAssistLoading" class="rounded-lg border border-violet-200 bg-violet-50 px-3 py-2.5 text-xs text-violet-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon="eos-icons:loading" class="size-14px" />
|
||||||
|
<span>{{ aiAssistStep || 'AI 分析中…' }}({{ aiAssistProgress }}%)</span>
|
||||||
|
</div>
|
||||||
|
<NProgress type="line" :percentage="aiAssistProgress" :show-indicator="false" class="mt-1.5" processing />
|
||||||
|
</div>
|
||||||
<!-- 合规警告条(强制合规项,不可关闭;闭环后转绿) -->
|
<!-- 合规警告条(强制合规项,不可关闭;闭环后转绿) -->
|
||||||
<div
|
<div
|
||||||
class="px-3 py-2.5 text-sm"
|
class="px-3 py-2.5 text-sm"
|
||||||
@@ -1064,7 +1177,7 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs leading-relaxed">
|
<p class="mt-1 text-xs leading-relaxed">
|
||||||
本次变更涉及 DCS 联锁设定值修改,依据 AQ/T 3034《化工企业工艺安全管理实施导则》,投用前须完成联锁测试并留存记录。
|
{{ mandText }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="!mandClosed" class="mt-1 text-right">
|
<div v-if="!mandClosed" class="mt-1 text-right">
|
||||||
<NButton text type="primary" class="text-xs" @click="confirmClose">确认闭环(留痕)</NButton>
|
<NButton text type="primary" class="text-xs" @click="confirmClose">确认闭环(留痕)</NButton>
|
||||||
@@ -1086,7 +1199,7 @@ onMounted(() => {
|
|||||||
<NButton text type="primary" class="ml-auto shrink-0 text-xs" @click="conflictHide = true">知道了</NButton>
|
<NButton text type="primary" class="ml-auto shrink-0 text-xs" @click="conflictHide = true">知道了</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs leading-relaxed">
|
<p class="mt-1 text-xs leading-relaxed">
|
||||||
涉及设备 R-101 关联变更 <b>MOC-2026-0038「机封冲洗方案调整」</b>正在审批中,请确认施工时序与隔离方案无冲突。
|
{{ conflictText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- 属地影响摘要(仅属地负责人视角) -->
|
<!-- 属地影响摘要(仅属地负责人视角) -->
|
||||||
@@ -1099,9 +1212,7 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5 px-3 py-2.5 text-xs leading-relaxed text-slate-600">
|
<div class="space-y-1.5 px-3 py-2.5 text-xs leading-relaxed text-slate-600">
|
||||||
<p><b class="text-slate-700">涉及单元:</b>一车间 R-201 反应釜及配套 DCS 回路,不影响相邻单元。</p>
|
<p v-for="(r, i) in deptRows" :key="i"><b class="text-slate-700">{{ r.label }}</b>{{ r.text }}</p>
|
||||||
<p><b class="text-slate-700">现行规程影响:</b>操作规程第 4.2 节(报警处置)需同步修订,变更关闭前完成受控换版。</p>
|
|
||||||
<p><b class="text-slate-700">交接班提示:</b>投用后首个班次需向班组交代新报警值与处置卡位置,建议纳入班前会内容。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 变更前后关键参数对比(变化标蓝 / 超限标红) -->
|
<!-- 变更前后关键参数对比(变化标蓝 / 超限标红) -->
|
||||||
@@ -1116,7 +1227,7 @@ onMounted(() => {
|
|||||||
<div class="border-b bg-slate-50/70 px-3 py-2 text-xs font-semibold text-slate-600">
|
<div class="border-b bg-slate-50/70 px-3 py-2 text-xs font-semibold text-slate-600">
|
||||||
关键参数对比(AI 提取) · <span class="font-bold text-[var(--theme-color)]">蓝=变化</span> · <span class="font-bold text-[var(--error-color)]">红=超限</span>
|
关键参数对比(AI 提取) · <span class="font-bold text-[var(--theme-color)]">蓝=变化</span> · <span class="font-bold text-[var(--error-color)]">红=超限</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="d in PARAM_DIFF" :key="d.k" class="border-b px-3 py-2 last:border-b-0">
|
<div v-for="d in paramDiff" :key="d.k" class="border-b px-3 py-2 last:border-b-0">
|
||||||
<div class="flex flex-wrap items-baseline gap-x-2 text-sm">
|
<div class="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||||
<span class="w-24 shrink-0 text-xs text-slate-500">{{ d.k }}</span>
|
<span class="w-24 shrink-0 text-xs text-slate-500">{{ d.k }}</span>
|
||||||
<span class="text-xs text-slate-400">{{ d.before }}</span>
|
<span class="text-xs text-slate-400">{{ d.before }}</span>
|
||||||
@@ -1159,7 +1270,7 @@ onMounted(() => {
|
|||||||
<!-- 强制合规项 -->
|
<!-- 强制合规项 -->
|
||||||
<div class="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--error-color)]"><span class="inline-block h-2 w-2 rounded-full bg-[var(--error-color)]" /> 强制合规项(驳回前必须处理,否则无法通过)</div>
|
<div class="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--error-color)]"><span class="inline-block h-2 w-2 rounded-full bg-[var(--error-color)]" /> 强制合规项(驳回前必须处理,否则无法通过)</div>
|
||||||
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-red-100 bg-red-50/50 px-3 py-2 text-sm">
|
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-red-100 bg-red-50/50 px-3 py-2 text-sm">
|
||||||
<span class="min-w-0 flex-1 text-slate-700">联锁测试记录须在投用前完成并上传归档</span>
|
<span class="min-w-0 flex-1 text-slate-700">{{ mandItem }}</span>
|
||||||
<NTag v-if="mandClosed" size="small" type="success">已闭环</NTag>
|
<NTag v-if="mandClosed" size="small" type="success">已闭环</NTag>
|
||||||
<NTag v-else size="small" type="warning">未闭环</NTag>
|
<NTag v-else size="small" type="warning">未闭环</NTag>
|
||||||
</div>
|
</div>
|
||||||
@@ -1191,11 +1302,11 @@ onMounted(() => {
|
|||||||
<div class="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--theme-color)]"><span class="inline-block h-2 w-2 rounded-full bg-[var(--theme-color)]" /> 参考信息(纯参考,无强制力)</div>
|
<div class="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--theme-color)]"><span class="inline-block h-2 w-2 rounded-full bg-[var(--theme-color)]" /> 参考信息(纯参考,无强制力)</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-[#DFE9F6] bg-[#EAF0F9]/60 px-3 py-2 text-sm">
|
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-[#DFE9F6] bg-[#EAF0F9]/60 px-3 py-2 text-sm">
|
||||||
<span class="min-w-0 flex-1 text-slate-700">相似变更 <b>MOC-2025-0112「酯化反应温度上调」</b>投用后运行正常,无异常记录</span>
|
<span class="min-w-0 flex-1 text-slate-700">{{ similarText }}</span>
|
||||||
<NButton text type="primary" class="hover:underline text-xs" @click="viewArchive('MOC-2025-0112')">查看归档包</NButton>
|
<NButton text type="primary" class="hover:underline text-xs" @click="viewArchive('MOC-2025-0112')">查看归档包</NButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-[#DFE9F6] bg-[#EAF0F9]/60 px-3 py-2 text-sm text-slate-700">
|
<div class="rounded-lg border border-[#DFE9F6] bg-[#EAF0F9]/60 px-3 py-2 text-sm text-slate-700">
|
||||||
历史经验:温度类变更投用后首周超温报警占比约 38%,建议关注投用首周报警趋势
|
{{ experienceText }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1822
-1678
File diff suppressed because it is too large
Load Diff
@@ -1,283 +1,305 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useMessage } from "naive-ui";
|
import { useMessage } from "naive-ui";
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import Ed from "./Ed.vue";
|
import { AiError, aiHazopDeviation } from '@/service/api/ai';
|
||||||
|
import Ed from "./Ed.vue";
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const message = useMessage();
|
const themeStore = useThemeStore();
|
||||||
const emit = defineEmits<{
|
const message = useMessage();
|
||||||
back: [];
|
const emit = defineEmits<{
|
||||||
done: [records: number];
|
back: [];
|
||||||
export: [items: any[], mode: "manual" | "ai"];
|
done: [records: number];
|
||||||
save: [];
|
export: [items: any[], mode: "manual" | "ai"];
|
||||||
}>();
|
save: [];
|
||||||
|
}>();
|
||||||
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
|
|
||||||
const TD = "border px-1.5 py-1 align-top";
|
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
|
||||||
const HAZOP_INIT = [
|
const TD = "border px-1.5 py-1 align-top";
|
||||||
{ name: "节点1:缩合反应釜 R-101", obj: "R-101 缩合反应釜", devs: [
|
const HAZOP_INIT = [
|
||||||
{ dev: "温度过高", rows: [
|
{ name: "节点1:缩合反应釜 R-101", obj: "R-101 缩合反应釜", devs: [
|
||||||
{ cause: "温控回路失效或冷却水中断;90℃ 新工艺窗口更接近溶剂回流温度,超温溜温概率上升", cons: "反应溜温冲料,釜压骤升,物料喷溅造成灼烫与环境污染", l: 3, s: 4, safe: "DCS 温度高报 95℃、高高报及联锁 100℃(随变更上调并重新整定验证);紧急冷却有效", sug: "投用前完成联锁测试并留存记录;投用首周每班复核温度趋势" },
|
{ dev: "温度过高", rows: [
|
||||||
{ cause: "未知杂质含量升高(0.05%→0.12%),热稳定性尚未定性,长期累积加速釜壁结垢", cons: "副反应加剧,可能引发二次分解放热,搅拌负荷异常", l: 2, s: 4, safe: "小试热稳定性筛查;安全阀泄放能力不受本次调整影响,仍然有效", sug: "将杂质含量纳入日常分析计划(每周 1 次,连续 8 周)" },
|
{ cause: "温控回路失效或冷却水中断;90℃ 新工艺窗口更接近溶剂回流温度,超温溜温概率上升", cons: "反应溜温冲料,釜压骤升,物料喷溅造成灼烫与环境污染", l: 3, s: 4, safe: "DCS 温度高报 95℃、高高报及联锁 100℃(随变更上调并重新整定验证);紧急冷却有效", sug: "投用前完成联锁测试并留存记录;投用首周每班复核温度趋势" },
|
||||||
] },
|
{ cause: "未知杂质含量升高(0.05%→0.12%),热稳定性尚未定性,长期累积加速釜壁结垢", cons: "副反应加剧,可能引发二次分解放热,搅拌负荷异常", l: 2, s: 4, safe: "小试热稳定性筛查;安全阀泄放能力不受本次调整影响,仍然有效", sug: "将杂质含量纳入日常分析计划(每周 1 次,连续 8 周)" },
|
||||||
{ dev: "温度过低", rows: [
|
] },
|
||||||
{ cause: "保温时间缩短至 4h,反应终点判断窗口变窄,人为误判", cons: "转化率不足 98%,批次报废或返工,增加处置风险", l: 3, s: 2, safe: "DCS 批次记录与转化率化验联动判定", sug: "明确反应终点取样判定规程,首 5 批加密化验" },
|
{ dev: "温度过低", rows: [
|
||||||
] },
|
{ cause: "保温时间缩短至 4h,反应终点判断窗口变窄,人为误判", cons: "转化率不足 98%,批次报废或返工,增加处置风险", l: 3, s: 2, safe: "DCS 批次记录与转化率化验联动判定", sug: "明确反应终点取样判定规程,首 5 批加密化验" },
|
||||||
{ dev: "搅拌失效", rows: [] },
|
] },
|
||||||
{ dev: "冷却水中断", rows: [] },
|
{ dev: "搅拌失效", rows: [] },
|
||||||
] },
|
{ dev: "冷却水中断", rows: [] },
|
||||||
{ name: "节点2:DCS 温控与联锁", obj: "DCS 温控回路及联锁", devs: [
|
] },
|
||||||
{ dev: "报警 / 联锁值设置不当", rows: [
|
{ name: "节点2:DCS 温控与联锁", obj: "DCS 温控回路及联锁", devs: [
|
||||||
{ cause: "报警 / 联锁值上调后(高报 85→95℃、联锁 90→100℃),原有报警裕度改变", cons: "误报、漏报导致操作人员对真实超温失去预警", l: 2, s: 4, safe: "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对", sug: "联锁逻辑图与台账同步更新,纳入变更资料归档" },
|
{ dev: "报警 / 联锁值设置不当", rows: [
|
||||||
] },
|
{ cause: "报警 / 联锁值上调后(高报 85→95℃、联锁 90→100℃),原有报警裕度改变", cons: "误报、漏报导致操作人员对真实超温失去预警", l: 2, s: 4, safe: "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对", sug: "联锁逻辑图与台账同步更新,纳入变更资料归档" },
|
||||||
{ dev: "联锁拒动 / 误动", rows: [] },
|
] },
|
||||||
] },
|
{ dev: "联锁拒动 / 误动", rows: [] },
|
||||||
];
|
] },
|
||||||
const HAZOP_AI:any = {
|
];
|
||||||
"搅拌失效": [
|
const HAZOP_AI:any = {
|
||||||
{ cause: "搅拌器机械故障或变频器跳闸,釜内物料传热不均", cons: "局部过热引发暴沸冲料;温度测量失真误导操作", l: 2, s: 4, safe: "搅拌电流 DCS 在线监视与低电流报警;跳闸联锁停进料", sug: "变更后复核搅拌电流裕量,纳入交接班巡检" },
|
"搅拌失效": [
|
||||||
],
|
{ cause: "搅拌器机械故障或变频器跳闸,釜内物料传热不均", cons: "局部过热引发暴沸冲料;温度测量失真误导操作", l: 2, s: 4, safe: "搅拌电流 DCS 在线监视与低电流报警;跳闸联锁停进料", sug: "变更后复核搅拌电流裕量,纳入交接班巡检" },
|
||||||
"冷却水中断": [
|
],
|
||||||
{ cause: "冷却水总管压力波动或阀门误关", cons: "撤热失效,反应温度快速上升溜温", l: 2, s: 5, safe: "冷却水流量低报警;紧急切断进料联锁", sug: "核实紧急冷却旁路可用性,写入超温应急处置卡" },
|
"冷却水中断": [
|
||||||
],
|
{ cause: "冷却水总管压力波动或阀门误关", cons: "撤热失效,反应温度快速上升溜温", l: 2, s: 5, safe: "冷却水流量低报警;紧急切断进料联锁", sug: "核实紧急冷却旁路可用性,写入超温应急处置卡" },
|
||||||
"联锁拒动 / 误动": [
|
],
|
||||||
{ cause: "联锁设定值修改后未重新整定验证,或逻辑修改错误", cons: "超温时联锁拒动失去最后保护层;误动造成非计划停车", l: 1, s: 5, safe: "投用前 100% 联锁传动测试,动作正确率确认", sug: "联锁测试记录归档至变更资料;修改执行双人复核" },
|
"联锁拒动 / 误动": [
|
||||||
],
|
{ cause: "联锁设定值修改后未重新整定验证,或逻辑修改错误", cons: "超温时联锁拒动失去最后保护层;误动造成非计划停车", l: 1, s: 5, safe: "投用前 100% 联锁传动测试,动作正确率确认", sug: "联锁测试记录归档至变更资料;修改执行双人复核" },
|
||||||
};
|
],
|
||||||
const inherentOf = (l: number, s: number): "高" | "中" | "低" => {
|
};
|
||||||
const v = l * s;
|
const inherentOf = (l: number, s: number): "高" | "中" | "低" => {
|
||||||
return v >= 10 ? "高" : v >= 5 ? "中" : "低";
|
const v = l * s;
|
||||||
}
|
return v >= 10 ? "高" : v >= 5 ? "中" : "低";
|
||||||
const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
|
}
|
||||||
return lv === "高" ? "中" : "低";
|
const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
|
||||||
}
|
return lv === "高" ? "中" : "低";
|
||||||
|
}
|
||||||
const nodes = ref<any>(JSON.parse(JSON.stringify(HAZOP_INIT)));
|
|
||||||
const sel = ref({ n: 0, d: 0 });
|
const nodes = ref<any>(JSON.parse(JSON.stringify(HAZOP_INIT)));
|
||||||
const analyzing = ref(false);
|
const sel = ref({ n: 0, d: 0 });
|
||||||
|
const analyzing = ref(false);
|
||||||
const cur = computed(() => nodes.value[sel.value.n].devs[sel.value.d]);
|
|
||||||
const devTotal = computed(() => nodes.value.reduce((a, n) => a + n.devs.length, 0));
|
const cur = computed(() => nodes.value[sel.value.n].devs[sel.value.d]);
|
||||||
const devDone = computed(() => nodes.value.reduce((a, n) => a + n.devs.filter((d) => d.rows.length > 0).length, 0));
|
const devTotal = computed(() => nodes.value.reduce((a, n) => a + n.devs.length, 0));
|
||||||
const recTotal = computed(() => nodes.value.reduce((a, n) => a + n.devs.reduce((b, d) => b + d.rows.length, 0), 0));
|
const devDone = computed(() => nodes.value.reduce((a, n) => a + n.devs.filter((d) => d.rows.length > 0).length, 0));
|
||||||
|
const recTotal = computed(() => nodes.value.reduce((a, n) => a + n.devs.reduce((b, d) => b + d.rows.length, 0), 0));
|
||||||
const toRec = (obj: string, dev: string, row: any): any => ({
|
|
||||||
item: `${dev}(${obj})`,
|
const toRec = (obj: string, dev: string, row: any): any => ({
|
||||||
scene: `${row.cause};可能导致:${row.cons}`,
|
item: `${dev}(${obj})`,
|
||||||
inherent: inherentOf(row.l, row.s),
|
scene: `${row.cause};可能导致:${row.cons}`,
|
||||||
existing: row.safe,
|
inherent: inherentOf(row.l, row.s),
|
||||||
suggest: row.sug,
|
existing: row.safe,
|
||||||
residual: lowerOf(inherentOf(row.l, row.s)),
|
suggest: row.sug,
|
||||||
source: "HAZOP",
|
residual: lowerOf(inherentOf(row.l, row.s)),
|
||||||
});
|
source: "HAZOP",
|
||||||
|
});
|
||||||
const updRow = (ri: number, patch: Partial<any>) => {
|
|
||||||
const row = cur.value.rows[ri];
|
const updRow = (ri: number, patch: Partial<any>) => {
|
||||||
if (row) Object.assign(row, patch);
|
const row = cur.value.rows[ri];
|
||||||
};
|
if (row) Object.assign(row, patch);
|
||||||
const clampLS = (v: string) => Math.max(1, Math.min(5, +v || 1));
|
};
|
||||||
|
const clampLS = (v: string) => Math.max(1, Math.min(5, +v || 1));
|
||||||
const runAi = () => {
|
|
||||||
if (cur.value.rows.length) return message.info("当前偏离已有分析记录,可手动新增行补充");
|
const runAi = async () => {
|
||||||
analyzing.value = true;
|
if (cur.value.rows.length) return message.info("当前偏离已有分析记录,可手动新增行补充");
|
||||||
setTimeout(() => {
|
analyzing.value = true;
|
||||||
const gen = HAZOP_AI[cur.value.dev] ?? [
|
try {
|
||||||
{ cause: "(AI 生成)偏离场景原因分析", cons: "(AI 生成)可能后果描述", l: 2, s: 3, safe: "现有保护层说明", sug: "建议补充的管控措施" },
|
const node = nodes.value[sel.value.n];
|
||||||
];
|
const res = await aiHazopDeviation({
|
||||||
cur.value.rows = gen.map((x) => ({ ...x }));
|
node_name: node.name,
|
||||||
analyzing.value = false;
|
deviation: cur.value.dev,
|
||||||
message.success(`AI 已完成「${cur.value.dev}」偏离分析,生成 ${gen.length} 条记录(支持手动修改)`);
|
object_name: node.obj,
|
||||||
}, 1100);
|
change_context: {
|
||||||
};
|
change: "缩合反应温度及时间优化",
|
||||||
|
content: "R-101 缩合反应釜反应温度由 80℃ 提升至 90℃、保温时间由 8h 缩短至 4h,DCS 温度报警 / 联锁值随之上调",
|
||||||
const addRow = () => cur.value.rows.push({ cause: "", cons: "", l: 2, s: 3, safe: "", sug: "" });
|
},
|
||||||
const delRow = (ri: number) => cur.value.rows.splice(ri, 1);
|
});
|
||||||
const exportAll = () => {
|
const rows = (res?.rows ?? []).map((r) => ({
|
||||||
emit("export", nodes.value.flatMap((n) => n.devs.flatMap((d) => d.rows.map((row) => toRec(n.obj, d.dev, row)))), "ai");
|
cause: r.cause ?? "", cons: r.consequence ?? "", l: r.l ?? 2, s: r.s ?? 3,
|
||||||
}
|
safe: r.safeguards ?? "", sug: r.suggestions ?? "",
|
||||||
const save = () => {
|
}));
|
||||||
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
if (!rows.length) throw new AiError(0, "AI 未返回分析结果");
|
||||||
};
|
cur.value.rows = rows;
|
||||||
|
message.success(`AI 已完成「${cur.value.dev}」偏离分析,生成 ${rows.length} 条记录(支持手动修改)`);
|
||||||
const ANALYSIS_POOL = [
|
} catch (e: any) {
|
||||||
{ name: "张工艺", org: "一车间", post: "工艺工程师" },
|
const msg = e instanceof AiError ? e.message : "AI 服务异常";
|
||||||
{ name: "王仪表", org: "技术部", post: "仪表工程师" },
|
window.$message?.warning(`AI 分析失败:${msg},可手动填写`);
|
||||||
{ name: "周设备", org: "设备科", post: "设备工程师" },
|
// 静态示例数据作 fallback,不阻塞页面
|
||||||
{ name: "刘文静", org: "技术部", post: "工艺员" },
|
const gen = HAZOP_AI[cur.value.dev] ?? [
|
||||||
{ name: "王海燕", org: "安全环保部", post: "安全工程师" },
|
{ cause: "(AI 生成)偏离场景原因分析", cons: "(AI 生成)可能后果描述", l: 2, s: 3, safe: "现有保护层说明", sug: "建议补充的管控措施" },
|
||||||
{ name: "李主任", org: "一车间", post: "车间主任" },
|
];
|
||||||
{ name: "吴海涛", org: "设备科", post: "维修班长" },
|
cur.value.rows = gen.map((x: any) => ({ ...x }));
|
||||||
{ name: "陈国栋", org: "一车间", post: "班长" },
|
} finally {
|
||||||
];
|
analyzing.value = false;
|
||||||
const open = ref(false);
|
}
|
||||||
const q = ref("");
|
};
|
||||||
const sels = ref<string[]>(["张工艺", "王仪表"]);
|
|
||||||
const list = computed(() => ANALYSIS_POOL.filter((x) => !q.value || x.name.includes(q.value)));
|
const addRow = () => cur.value.rows.push({ cause: "", cons: "", l: 2, s: 3, safe: "", sug: "" });
|
||||||
const toggle = (n: string) => {
|
const delRow = (ri: number) => cur.value.rows.splice(ri, 1);
|
||||||
sels.value = sels.value.includes(n) ? sels.value.filter((x) => x !== n) : [...sels.value, n];
|
const exportAll = () => {
|
||||||
};
|
emit("export", nodes.value.flatMap((n) => n.devs.flatMap((d) => d.rows.map((row) => toRec(n.obj, d.dev, row)))), "ai");
|
||||||
const rrOf = (l: number, s: number) => {
|
}
|
||||||
const v = l * s;
|
const save = () => {
|
||||||
if (v >= 15) return { t: `重大 (${v})`, c: "bg-red-500 text-white" };
|
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
||||||
if (v >= 10) return { t: `较大 (${v})`, c: "bg-[#F5A623] text-white" };
|
};
|
||||||
if (v >= 5) return { t: `一般 (${v})`, c: "bg-amber-300 text-amber-900" };
|
|
||||||
return { t: `低 (${v})`, c: "bg-emerald-500 text-white" };
|
const ANALYSIS_POOL = [
|
||||||
}
|
{ name: "张工艺", org: "一车间", post: "工艺工程师" },
|
||||||
</script>
|
{ name: "王仪表", org: "技术部", post: "仪表工程师" },
|
||||||
|
{ name: "周设备", org: "设备科", post: "设备工程师" },
|
||||||
<template>
|
{ name: "刘文静", org: "技术部", post: "工艺员" },
|
||||||
<div class="space-y-3">
|
{ name: "王海燕", org: "安全环保部", post: "安全工程师" },
|
||||||
<div
|
{ name: "李主任", org: "一车间", post: "车间主任" },
|
||||||
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
|
{ name: "吴海涛", org: "设备科", post: "维修班长" },
|
||||||
:style="{
|
{ name: "陈国栋", org: "一车间", post: "班长" },
|
||||||
'--theme-color': themeStore.themeColor
|
];
|
||||||
}"
|
const open = ref(false);
|
||||||
>
|
const q = ref("");
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="emit('back')">
|
const sels = ref<string[]>(["张工艺", "王仪表"]);
|
||||||
<Icon icon="basil:arrow-left-outline" class="size-18px" />
|
const list = computed(() => ANALYSIS_POOL.filter((x) => !q.value || x.name.includes(q.value)));
|
||||||
返回工具列表
|
const toggle = (n: string) => {
|
||||||
</NButton>
|
sels.value = sels.value.includes(n) ? sels.value.filter((x) => x !== n) : [...sels.value, n];
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
};
|
||||||
<span class="text-sm font-bold text-[var(--theme-color)]">HAZOP 分析插件</span>
|
const rrOf = (l: number, s: number) => {
|
||||||
<NTag size="small" type="info">插件</NTag>
|
const v = l * s;
|
||||||
<NTag size="small">MOC 变更:缩合反应温度及时间优化(演示)</NTag>
|
if (v >= 15) return { t: `重大 (${v})`, c: "bg-red-500 text-white" };
|
||||||
<span class="text-[11px] text-slate-500">{{ `分析进度 ${devDone}/${devTotal} 个偏离 · ${recTotal} 条记录` }}</span>
|
if (v >= 10) return { t: `较大 (${v})`, c: "bg-[#F5A623] text-white" };
|
||||||
<div class="relative">
|
if (v >= 5) return { t: `一般 (${v})`, c: "bg-amber-300 text-amber-900" };
|
||||||
<NButton size="small" ghost type="primary" class="text-[11px]" @click="open = !open">
|
return { t: `低 (${v})`, c: "bg-emerald-500 text-white" };
|
||||||
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ sels.length }} 人
|
}
|
||||||
</NButton>
|
</script>
|
||||||
<template v-if="open">
|
|
||||||
<div class="fixed inset-0 z-10" @click="open = false"></div>
|
<template>
|
||||||
<div class="absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl">
|
<div class="space-y-3">
|
||||||
<div class="mb-1.5 flex items-center justify-between">
|
<div
|
||||||
<span class="text-xs font-semibold text-slate-700">选择分析组成员(岗位人员库)</span>
|
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
|
||||||
<span class="text-[11px] text-slate-400">已选 {{ sels.length }} 人</span>
|
:style="{
|
||||||
</div>
|
'--theme-color': themeStore.themeColor
|
||||||
<NInput v-model:value="q" size="small" placeholder="搜索人名…" class="mb-1.5 text-xs" />
|
}"
|
||||||
<div class="max-h-48 space-y-0.5 overflow-y-auto">
|
>
|
||||||
<div v-for="x in list" :key="x.name" @click="toggle(x.name)"
|
<NButton size="small" ghost type="primary" class="text-xs" @click="emit('back')">
|
||||||
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
<Icon icon="basil:arrow-left-outline" class="size-18px" />
|
||||||
:class="sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
|
返回工具列表
|
||||||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
|
</NButton>
|
||||||
:class="sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ sels.includes(x.name) ? "✓" : "" }}</span>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
||||||
<span class="font-medium text-slate-800">{{ x.name }}</span>
|
<span class="text-sm font-bold text-[var(--theme-color)]">HAZOP 分析插件</span>
|
||||||
<span class="text-[11px] text-slate-400">{{ x.org }} · {{ x.post }}</span>
|
<NTag size="small" type="info">插件</NTag>
|
||||||
<NTag v-if="sels[0] === x.name" size="small" type="primary" class="ml-auto">组长</NTag>
|
<NTag size="small">MOC 变更:缩合反应温度及时间优化(演示)</NTag>
|
||||||
</div>
|
<span class="text-[11px] text-slate-500">{{ `分析进度 ${devDone}/${devTotal} 个偏离 · ${recTotal} 条记录` }}</span>
|
||||||
</div>
|
<div class="relative">
|
||||||
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
|
<NButton size="small" ghost type="primary" class="text-[11px]" @click="open = !open">
|
||||||
首位选择者默认为组长;成员为选择式(非手写),参与记录自动归属到人,用于「参与风险分析次数」统计采集。
|
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ sels.length }} 人
|
||||||
</div>
|
</NButton>
|
||||||
</div>
|
<template v-if="open">
|
||||||
</template>
|
<div class="fixed inset-0 z-10" @click="open = false"></div>
|
||||||
</div>
|
<div class="absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl">
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div class="mb-1.5 flex items-center justify-between">
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="exportAll">
|
<span class="text-xs font-semibold text-slate-700">选择分析组成员(岗位人员库)</span>
|
||||||
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
|
<span class="text-[11px] text-slate-400">已选 {{ sels.length }} 人</span>
|
||||||
</NButton>
|
</div>
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="message.success('HAZOP 分析报告已导出(Word)')">
|
<NInput v-model:value="q" size="small" placeholder="搜索人名…" class="mb-1.5 text-xs" />
|
||||||
<Icon icon="material-symbols:download" class="size-14px" /> 导出 Word
|
<div class="max-h-48 space-y-0.5 overflow-y-auto">
|
||||||
</NButton>
|
<div v-for="x in list" :key="x.name" @click="toggle(x.name)"
|
||||||
<NButton size="small" type="primary" class="text-xs" :disabled="analyzing" @click="runAi">
|
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
||||||
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
:class="sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
|
||||||
<Icon v-else icon="lucide:sparkles" class="size-12px" />
|
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
|
||||||
AI 分析当前偏离
|
:class="sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ sels.includes(x.name) ? "✓" : "" }}</span>
|
||||||
</NButton>
|
<span class="font-medium text-slate-800">{{ x.name }}</span>
|
||||||
</div>
|
<span class="text-[11px] text-slate-400">{{ x.org }} · {{ x.post }}</span>
|
||||||
</div>
|
<NTag v-if="sels[0] === x.name" size="small" type="primary" class="ml-auto">组长</NTag>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
</div>
|
||||||
<span>当前位置:</span>
|
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
|
||||||
<NTag size="small">{{ nodes[sel.n].name }}</NTag>
|
首位选择者默认为组长;成员为选择式(非手写),参与记录自动归属到人,用于「参与风险分析次数」统计采集。
|
||||||
<span>›</span>
|
</div>
|
||||||
<NTag size="small" type="success">分析对象:{{ nodes[sel.n].obj }}</NTag>
|
</div>
|
||||||
<span>›</span>
|
</template>
|
||||||
<NTag size="small" type="warning">偏离:{{ cur.dev }}</NTag>
|
</div>
|
||||||
</div>
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
|
<NButton size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="exportAll">
|
||||||
<div
|
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
|
||||||
class="flex gap-3"
|
</NButton>
|
||||||
:style="{
|
<NButton size="small" ghost type="primary" class="text-xs" @click="message.success('HAZOP 分析报告已导出(Word)')">
|
||||||
'--theme-color': themeStore.themeColor,
|
<Icon icon="material-symbols:download" class="size-14px" /> 导出 Word
|
||||||
'--success-color': themeStore.otherColor.success
|
</NButton>
|
||||||
}"
|
<NButton size="small" type="primary" class="text-xs" :disabled="analyzing" @click="runAi">
|
||||||
>
|
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
||||||
<!-- 左:节点 / 偏离树 -->
|
<Icon v-else icon="lucide:sparkles" class="size-12px" />
|
||||||
<div class="shrink-0 rounded-lg border bg-white p-2">
|
AI 分析当前偏离
|
||||||
<div v-for="(n, i) in nodes" :key="n.name" class="mb-1">
|
</NButton>
|
||||||
<div class="px-2 py-1.5 text-xs font-semibold text-slate-700">{{ n.name }}</div>
|
</div>
|
||||||
<div v-for="(d, j) in n.devs" :key="d.dev" @click="sel = { n: i, d: j }"
|
</div>
|
||||||
class="cursor-pointer flex w-full items-center gap-1.5 rounded-md px-3 py-1.5 text-left text-xs transition"
|
|
||||||
:class="sel.n === i && sel.d === j ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'">
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
<span class="flex-1">{{ d.dev }}</span>
|
<span>当前位置:</span>
|
||||||
<span class="rounded-full px-1.5 text-[10px]"
|
<NTag size="small">{{ nodes[sel.n].name }}</NTag>
|
||||||
:class="d.rows.length
|
<span>›</span>
|
||||||
? (sel.n === i && sel.d === j ? 'bg-white/25 text-white' : 'bg-emerald-100 text-[var(--success-color)]')
|
<NTag size="small" type="success">分析对象:{{ nodes[sel.n].obj }}</NTag>
|
||||||
: (sel.n === i && sel.d === j ? 'bg-white/30 text-white' : 'bg-slate-100 text-slate-400')">
|
<span>›</span>
|
||||||
{{ d.rows.length ? `${d.rows.length} 条` : "待分析" }}
|
<NTag size="small" type="warning">偏离:{{ cur.dev }}</NTag>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div
|
||||||
</div>
|
class="flex gap-3"
|
||||||
<!-- 右:HAZOP 记录表 -->
|
:style="{
|
||||||
<div class="min-w-0 flex-1 bg-white">
|
'--theme-color': themeStore.themeColor,
|
||||||
<div class="flex items-center gap-2 pb-2">
|
'--success-color': themeStore.otherColor.success
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="addRow">
|
}"
|
||||||
<Icon icon="ic:round-plus" class="size-14px" /> 新增行
|
>
|
||||||
</NButton>
|
<!-- 左:节点 / 偏离树 -->
|
||||||
<span class="text-[11px] text-slate-400">单元格点击即可修改;L / S 取值 1-5,风险等级按风险矩阵实时重算;「入表」将本条选入风险分析记录表</span>
|
<div class="shrink-0 rounded-lg border bg-white p-2">
|
||||||
</div>
|
<div v-for="(n, i) in nodes" :key="n.name" class="mb-1">
|
||||||
<table class="w-full min-w-[860px] border-collapse text-xs">
|
<div class="px-2 py-1.5 text-xs font-semibold text-slate-700">{{ n.name }}</div>
|
||||||
<thead>
|
<div v-for="(d, j) in n.devs" :key="d.dev" @click="sel = { n: i, d: j }"
|
||||||
<tr>
|
class="cursor-pointer flex w-full items-center gap-1.5 rounded-md px-3 py-1.5 text-left text-xs transition"
|
||||||
<th :class="TH" class="w-16">操作</th>
|
:class="sel.n === i && sel.d === j ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'">
|
||||||
<th :class="TH">原因</th>
|
<span class="flex-1">{{ d.dev }}</span>
|
||||||
<th :class="TH">后果</th>
|
<span class="rounded-full px-1.5 text-[10px]"
|
||||||
<th :class="TH" class="w-8">L</th>
|
:class="d.rows.length
|
||||||
<th :class="TH" class="w-8">S</th>
|
? (sel.n === i && sel.d === j ? 'bg-white/25 text-white' : 'bg-emerald-100 text-[var(--success-color)]')
|
||||||
<th :class="TH" class="w-20">RR</th>
|
: (sel.n === i && sel.d === j ? 'bg-white/30 text-white' : 'bg-slate-100 text-slate-400')">
|
||||||
<th :class="TH">安全措施</th>
|
{{ d.rows.length ? `${d.rows.length} 条` : "待分析" }}
|
||||||
<th :class="TH">建议措施</th>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</div>
|
||||||
<tr v-if="cur.rows.length === 0">
|
<!-- 右:HAZOP 记录表 -->
|
||||||
<td colspan="8" class="px-3 py-8 text-center text-xs text-slate-400">
|
<div class="min-w-0 flex-1 bg-white">
|
||||||
当前偏离暂无分析记录,点击右上角「AI 分析当前偏离」自动生成,或「新增行」手动填写
|
<div class="flex items-center gap-2 pb-2">
|
||||||
</td>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="addRow">
|
||||||
</tr>
|
<Icon icon="ic:round-plus" class="size-14px" /> 新增行
|
||||||
<tr v-for="(row, ri) in cur.rows" :key="ri">
|
</NButton>
|
||||||
<td :class="TD">
|
<span class="text-[11px] text-slate-400">单元格点击即可修改;L / S 取值 1-5,风险等级按风险矩阵实时重算;「入表」将本条选入风险分析记录表</span>
|
||||||
<div class="flex items-center justify-between px-1 mt-1">
|
</div>
|
||||||
<NButton text type="primary" class="mt-1px text-[11px] hover:underline" title="选入风险分析记录表"
|
<table class="w-full min-w-[860px] border-collapse text-xs">
|
||||||
@click="message.success('已选入 1 条风险记录,可继续挑选或返回查看记录表')">入表
|
<thead>
|
||||||
</NButton>
|
<tr>
|
||||||
<NButton text type="error" title="删除行" @click="delRow(ri)">
|
<th :class="TH" class="w-16">操作</th>
|
||||||
<Icon icon="ep:delete" class="size-14px" />
|
<th :class="TH">原因</th>
|
||||||
</NButton>
|
<th :class="TH">后果</th>
|
||||||
</div>
|
<th :class="TH" class="w-8">L</th>
|
||||||
</td>
|
<th :class="TH" class="w-8">S</th>
|
||||||
<td :class="TD"><Ed :v="row.cause" @update="(x) => updRow(ri, { cause: x })" /></td>
|
<th :class="TH" class="w-20">RR</th>
|
||||||
<td :class="TD"><Ed :v="row.cons" @update="(x) => updRow(ri, { cons: x })" /></td>
|
<th :class="TH">安全措施</th>
|
||||||
<td :class="TD" class="text-center">
|
<th :class="TH">建议措施</th>
|
||||||
<input :value="row.l" @change="(e) => updRow(ri, { l: clampLS((e.target as HTMLInputElement).value) })"
|
</tr>
|
||||||
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
</thead>
|
||||||
</td>
|
<tbody>
|
||||||
<td :class="TD" class="text-center">
|
<tr v-if="cur.rows.length === 0">
|
||||||
<input :value="row.s" @change="(e) => updRow(ri, { s: clampLS((e.target as HTMLInputElement).value) })"
|
<td colspan="8" class="px-3 py-8 text-center text-xs text-slate-400">
|
||||||
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
当前偏离暂无分析记录,点击右上角「AI 分析当前偏离」自动生成,或「新增行」手动填写
|
||||||
</td>
|
</td>
|
||||||
<td :class="TD" class="text-center">
|
</tr>
|
||||||
<span class="inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" :class="rrOf(row.l, row.s).c">{{ rrOf(row.l, row.s).t }}</span>
|
<tr v-for="(row, ri) in cur.rows" :key="ri">
|
||||||
</td>
|
<td :class="TD">
|
||||||
<td :class="TD"><Ed :v="row.safe" @update="(x) => updRow(ri, { safe: x })" /></td>
|
<div class="flex items-center justify-between px-1 mt-1">
|
||||||
<td :class="TD"><Ed :v="row.sug" @update="(x) => updRow(ri, { sug: x })" /></td>
|
<NButton text type="primary" class="mt-1px text-[11px] hover:underline" title="选入风险分析记录表"
|
||||||
</tr>
|
@click="message.success('已选入 1 条风险记录,可继续挑选或返回查看记录表')">入表
|
||||||
</tbody>
|
</NButton>
|
||||||
</table>
|
<NButton text type="error" title="删除行" @click="delRow(ri)">
|
||||||
</div>
|
<Icon icon="ep:delete" class="size-14px" />
|
||||||
</div>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</td>
|
||||||
|
<td :class="TD"><Ed :v="row.cause" @update="(x) => updRow(ri, { cause: x })" /></td>
|
||||||
|
<td :class="TD"><Ed :v="row.cons" @update="(x) => updRow(ri, { cons: x })" /></td>
|
||||||
|
<td :class="TD" class="text-center">
|
||||||
|
<input :value="row.l" @change="(e) => updRow(ri, { l: clampLS((e.target as HTMLInputElement).value) })"
|
||||||
|
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
||||||
|
</td>
|
||||||
|
<td :class="TD" class="text-center">
|
||||||
|
<input :value="row.s" @change="(e) => updRow(ri, { s: clampLS((e.target as HTMLInputElement).value) })"
|
||||||
|
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
||||||
|
</td>
|
||||||
|
<td :class="TD" class="text-center">
|
||||||
|
<span class="inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" :class="rrOf(row.l, row.s).c">{{ rrOf(row.l, row.s).t }}</span>
|
||||||
|
</td>
|
||||||
|
<td :class="TD"><Ed :v="row.safe" @update="(x) => updRow(ri, { safe: x })" /></td>
|
||||||
|
<td :class="TD"><Ed :v="row.sug" @update="(x) => updRow(ri, { sug: x })" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -1,281 +1,303 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useMessage } from "naive-ui";
|
import { useMessage } from "naive-ui";
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import Ed from "./Ed.vue";
|
import { AiError, aiJsaAnalyze } from '@/service/api/ai';
|
||||||
|
import Ed from "./Ed.vue";
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const message = useMessage();
|
const themeStore = useThemeStore();
|
||||||
const emit = defineEmits<{
|
const message = useMessage();
|
||||||
back: [];
|
const emit = defineEmits<{
|
||||||
done: [records: number];
|
back: [];
|
||||||
export: [items: any[], mode: "manual" | "ai"];
|
done: [records: number];
|
||||||
save: [];
|
export: [items: any[], mode: "manual" | "ai"];
|
||||||
}>();
|
save: [];
|
||||||
|
}>();
|
||||||
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
|
|
||||||
const TD = "border px-1.5 py-1 align-top";
|
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
|
||||||
const HAZOP_INIT = [
|
const TD = "border px-1.5 py-1 align-top";
|
||||||
{ name: "节点1:缩合反应釜 R-101", obj: "R-101 缩合反应釜", devs: [
|
const HAZOP_INIT = [
|
||||||
{ dev: "温度过高", rows: [
|
{ name: "节点1:缩合反应釜 R-101", obj: "R-101 缩合反应釜", devs: [
|
||||||
{ cause: "温控回路失效或冷却水中断;90℃ 新工艺窗口更接近溶剂回流温度,超温溜温概率上升", cons: "反应溜温冲料,釜压骤升,物料喷溅造成灼烫与环境污染", l: 3, s: 4, safe: "DCS 温度高报 95℃、高高报及联锁 100℃(随变更上调并重新整定验证);紧急冷却有效", sug: "投用前完成联锁测试并留存记录;投用首周每班复核温度趋势" },
|
{ dev: "温度过高", rows: [
|
||||||
{ cause: "未知杂质含量升高(0.05%→0.12%),热稳定性尚未定性,长期累积加速釜壁结垢", cons: "副反应加剧,可能引发二次分解放热,搅拌负荷异常", l: 2, s: 4, safe: "小试热稳定性筛查;安全阀泄放能力不受本次调整影响,仍然有效", sug: "将杂质含量纳入日常分析计划(每周 1 次,连续 8 周)" },
|
{ cause: "温控回路失效或冷却水中断;90℃ 新工艺窗口更接近溶剂回流温度,超温溜温概率上升", cons: "反应溜温冲料,釜压骤升,物料喷溅造成灼烫与环境污染", l: 3, s: 4, safe: "DCS 温度高报 95℃、高高报及联锁 100℃(随变更上调并重新整定验证);紧急冷却有效", sug: "投用前完成联锁测试并留存记录;投用首周每班复核温度趋势" },
|
||||||
] },
|
{ cause: "未知杂质含量升高(0.05%→0.12%),热稳定性尚未定性,长期累积加速釜壁结垢", cons: "副反应加剧,可能引发二次分解放热,搅拌负荷异常", l: 2, s: 4, safe: "小试热稳定性筛查;安全阀泄放能力不受本次调整影响,仍然有效", sug: "将杂质含量纳入日常分析计划(每周 1 次,连续 8 周)" },
|
||||||
{ dev: "温度过低", rows: [
|
] },
|
||||||
{ cause: "保温时间缩短至 4h,反应终点判断窗口变窄,人为误判", cons: "转化率不足 98%,批次报废或返工,增加处置风险", l: 3, s: 2, safe: "DCS 批次记录与转化率化验联动判定", sug: "明确反应终点取样判定规程,首 5 批加密化验" },
|
{ dev: "温度过低", rows: [
|
||||||
] },
|
{ cause: "保温时间缩短至 4h,反应终点判断窗口变窄,人为误判", cons: "转化率不足 98%,批次报废或返工,增加处置风险", l: 3, s: 2, safe: "DCS 批次记录与转化率化验联动判定", sug: "明确反应终点取样判定规程,首 5 批加密化验" },
|
||||||
{ dev: "搅拌失效", rows: [] },
|
] },
|
||||||
{ dev: "冷却水中断", rows: [] },
|
{ dev: "搅拌失效", rows: [] },
|
||||||
] },
|
{ dev: "冷却水中断", rows: [] },
|
||||||
{ name: "节点2:DCS 温控与联锁", obj: "DCS 温控回路及联锁", devs: [
|
] },
|
||||||
{ dev: "报警 / 联锁值设置不当", rows: [
|
{ name: "节点2:DCS 温控与联锁", obj: "DCS 温控回路及联锁", devs: [
|
||||||
{ cause: "报警 / 联锁值上调后(高报 85→95℃、联锁 90→100℃),原有报警裕度改变", cons: "误报、漏报导致操作人员对真实超温失去预警", l: 2, s: 4, safe: "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对", sug: "联锁逻辑图与台账同步更新,纳入变更资料归档" },
|
{ dev: "报警 / 联锁值设置不当", rows: [
|
||||||
] },
|
{ cause: "报警 / 联锁值上调后(高报 85→95℃、联锁 90→100℃),原有报警裕度改变", cons: "误报、漏报导致操作人员对真实超温失去预警", l: 2, s: 4, safe: "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对", sug: "联锁逻辑图与台账同步更新,纳入变更资料归档" },
|
||||||
{ dev: "联锁拒动 / 误动", rows: [] },
|
] },
|
||||||
] },
|
{ dev: "联锁拒动 / 误动", rows: [] },
|
||||||
];
|
] },
|
||||||
const HAZOP_AI:any = {
|
];
|
||||||
"搅拌失效": [
|
const HAZOP_AI:any = {
|
||||||
{ cause: "搅拌器机械故障或变频器跳闸,釜内物料传热不均", cons: "局部过热引发暴沸冲料;温度测量失真误导操作", l: 2, s: 4, safe: "搅拌电流 DCS 在线监视与低电流报警;跳闸联锁停进料", sug: "变更后复核搅拌电流裕量,纳入交接班巡检" },
|
"搅拌失效": [
|
||||||
],
|
{ cause: "搅拌器机械故障或变频器跳闸,釜内物料传热不均", cons: "局部过热引发暴沸冲料;温度测量失真误导操作", l: 2, s: 4, safe: "搅拌电流 DCS 在线监视与低电流报警;跳闸联锁停进料", sug: "变更后复核搅拌电流裕量,纳入交接班巡检" },
|
||||||
"冷却水中断": [
|
],
|
||||||
{ cause: "冷却水总管压力波动或阀门误关", cons: "撤热失效,反应温度快速上升溜温", l: 2, s: 5, safe: "冷却水流量低报警;紧急切断进料联锁", sug: "核实紧急冷却旁路可用性,写入超温应急处置卡" },
|
"冷却水中断": [
|
||||||
],
|
{ cause: "冷却水总管压力波动或阀门误关", cons: "撤热失效,反应温度快速上升溜温", l: 2, s: 5, safe: "冷却水流量低报警;紧急切断进料联锁", sug: "核实紧急冷却旁路可用性,写入超温应急处置卡" },
|
||||||
"联锁拒动 / 误动": [
|
],
|
||||||
{ cause: "联锁设定值修改后未重新整定验证,或逻辑修改错误", cons: "超温时联锁拒动失去最后保护层;误动造成非计划停车", l: 1, s: 5, safe: "投用前 100% 联锁传动测试,动作正确率确认", sug: "联锁测试记录归档至变更资料;修改执行双人复核" },
|
"联锁拒动 / 误动": [
|
||||||
],
|
{ cause: "联锁设定值修改后未重新整定验证,或逻辑修改错误", cons: "超温时联锁拒动失去最后保护层;误动造成非计划停车", l: 1, s: 5, safe: "投用前 100% 联锁传动测试,动作正确率确认", sug: "联锁测试记录归档至变更资料;修改执行双人复核" },
|
||||||
};
|
],
|
||||||
const inherentOf = (l: number, s: number): "高" | "中" | "低" => {
|
};
|
||||||
const v = l * s;
|
const inherentOf = (l: number, s: number): "高" | "中" | "低" => {
|
||||||
return v >= 10 ? "高" : v >= 5 ? "中" : "低";
|
const v = l * s;
|
||||||
}
|
return v >= 10 ? "高" : v >= 5 ? "中" : "低";
|
||||||
const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
|
}
|
||||||
return lv === "高" ? "中" : "低";
|
const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
|
||||||
}
|
return lv === "高" ? "中" : "低";
|
||||||
|
}
|
||||||
const nodes = ref<any>(JSON.parse(JSON.stringify(HAZOP_INIT)));
|
|
||||||
const sel = ref({ n: 0, d: 0 });
|
const nodes = ref<any>(JSON.parse(JSON.stringify(HAZOP_INIT)));
|
||||||
const analyzing = ref(false);
|
const sel = ref({ n: 0, d: 0 });
|
||||||
|
const analyzing = ref(false);
|
||||||
const recTotal = computed(() => steps.value.reduce((a, s) => a + s.rows.length, 0));
|
|
||||||
|
const recTotal = computed(() => steps.value.reduce((a, s) => a + s.rows.length, 0));
|
||||||
const updRow = (si: number, ri: number, patch: Partial<any>) => {
|
|
||||||
const row = steps.value[si]?.rows[ri];
|
const updRow = (si: number, ri: number, patch: Partial<any>) => {
|
||||||
if (row) Object.assign(row, patch);
|
const row = steps.value[si]?.rows[ri];
|
||||||
};
|
if (row) Object.assign(row, patch);
|
||||||
const clampLS = (v: string) => Math.max(1, Math.min(5, +v || 1));
|
};
|
||||||
|
const clampLS = (v: string) => Math.max(1, Math.min(5, +v || 1));
|
||||||
const runAi = () => {
|
|
||||||
analyzing.value = true;
|
const runAi = async () => {
|
||||||
setTimeout(() => {
|
analyzing.value = true;
|
||||||
steps.value[steps.value.length - 1].rows.push({
|
try {
|
||||||
hazard: "新版操作规程执行不熟练", cons: "误操作导致温度 / 时间偏离工艺窗口", l: 2, s: 3,
|
const hasRows = steps.value.some((s) => s.rows.length > 0);
|
||||||
safe: "培训考核合格后上岗;首批执行班长旁站确认", sug: "投用首周班长复核关键操作并记录",
|
const res = await aiJsaAnalyze({
|
||||||
});
|
job_info: { job_name: "变更实施作业", job_location: "一车间 R-101 区域" },
|
||||||
analyzing.value = false;
|
steps: steps.value.map((s) => ({ step: s.step, desc: s.desc })),
|
||||||
message.success("AI 危害因素分析完成:补充 1 条记录(支持手动修改)");
|
mode: hasRows ? "supplement" : "full",
|
||||||
}, 1100);
|
change_context: { content: info.value },
|
||||||
};
|
});
|
||||||
|
let added = 0;
|
||||||
|
(res?.steps ?? []).forEach((st) => {
|
||||||
const addRow = () => steps.value[steps.value.length - 1].rows.push({ hazard: "", cons: "", l: 2, s: 3, safe: "", sug: "" });
|
const rows = (st.rows ?? []).map((r) => ({
|
||||||
const delRow = (si: number, ri: number) => steps.value[si].rows.splice(ri, 1);
|
hazard: r.hazard ?? "", cons: r.consequence ?? "", l: r.l ?? 2, s: r.s ?? 3,
|
||||||
const exportAll = () => emit("export", steps.value.flatMap((s) => s.rows.map((row) => toRec(s.step, row))), "ai");
|
safe: r.safeguards ?? "", sug: r.suggestions ?? "",
|
||||||
const save = () => {
|
}));
|
||||||
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
if (!rows.length) return;
|
||||||
};
|
// 按步骤名匹配合并,匹配不到则追加为新步骤
|
||||||
|
const target = steps.value.find((s) => st.step && (s.step.includes(st.step) || st.step.includes(s.step)));
|
||||||
const ANALYSIS_POOL = [
|
if (target) target.rows.push(...rows);
|
||||||
{ name: "张工艺", org: "一车间", post: "工艺工程师" },
|
else steps.value.push({ step: st.step || `步骤${steps.value.length + 1}`, desc: st.desc ?? "", rows });
|
||||||
{ name: "王仪表", org: "技术部", post: "仪表工程师" },
|
added += rows.length;
|
||||||
{ name: "周设备", org: "设备科", post: "设备工程师" },
|
});
|
||||||
{ name: "刘文静", org: "技术部", post: "工艺员" },
|
if (!added) throw new AiError(0, "AI 未返回分析结果");
|
||||||
{ name: "王海燕", org: "安全环保部", post: "安全工程师" },
|
message.success(`AI 危害因素分析完成:补充 ${added} 条记录(支持手动修改)`);
|
||||||
{ name: "李主任", org: "一车间", post: "车间主任" },
|
} catch (e: any) {
|
||||||
{ name: "吴海涛", org: "设备科", post: "维修班长" },
|
const msg = e instanceof AiError ? e.message : "AI 服务异常";
|
||||||
{ name: "陈国栋", org: "一车间", post: "班长" },
|
window.$message?.warning(`AI 分析失败:${msg},可手动填写`);
|
||||||
];
|
} finally {
|
||||||
const open = ref(false);
|
analyzing.value = false;
|
||||||
const q = ref("");
|
}
|
||||||
const sels = ref<string[]>(["张工艺", "王仪表"]);
|
};
|
||||||
const list = computed(() => ANALYSIS_POOL.filter((x) => !q.value || x.name.includes(q.value)));
|
|
||||||
const toggle = (n: string) => {
|
|
||||||
sels.value = sels.value.includes(n) ? sels.value.filter((x) => x !== n) : [...sels.value, n];
|
const addRow = () => steps.value[steps.value.length - 1].rows.push({ hazard: "", cons: "", l: 2, s: 3, safe: "", sug: "" });
|
||||||
};
|
const delRow = (si: number, ri: number) => steps.value[si].rows.splice(ri, 1);
|
||||||
const rrOf = (l: number, s: number) => {
|
const exportAll = () => emit("export", steps.value.flatMap((s) => s.rows.map((row) => toRec(s.step, row))), "ai");
|
||||||
const v = l * s;
|
const save = () => {
|
||||||
if (v >= 15) return { t: `重大 (${v})`, c: "bg-red-500 text-white" };
|
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
||||||
if (v >= 10) return { t: `较大 (${v})`, c: "bg-[#F5A623] text-white" };
|
};
|
||||||
if (v >= 5) return { t: `一般 (${v})`, c: "bg-amber-300 text-amber-900" };
|
|
||||||
return { t: `低 (${v})`, c: "bg-emerald-500 text-white" };
|
const ANALYSIS_POOL = [
|
||||||
}
|
{ name: "张工艺", org: "一车间", post: "工艺工程师" },
|
||||||
|
{ name: "王仪表", org: "技术部", post: "仪表工程师" },
|
||||||
const steps = ref([
|
{ name: "周设备", org: "设备科", post: "设备工程师" },
|
||||||
{ step: "步骤1:作业准备与票证办理", desc: "作业方案交底、作业许可办理、能量状态确认", rows: [
|
{ name: "刘文静", org: "技术部", post: "工艺员" },
|
||||||
{ hazard: "高温介质 / 蒸汽意外释放", cons: "隔离确认不到位导致人员灼烫", l: 2, s: 4, safe: "作业前确认釜体温度 ≤40℃、管线泄压排空;办理作业票证并现场交底", sug: "隔离点挂牌上锁(LOTO),作业前双人确认" },
|
{ name: "王海燕", org: "安全环保部", post: "安全工程师" },
|
||||||
] },
|
{ name: "李主任", org: "一车间", post: "车间主任" },
|
||||||
{ step: "步骤2:旧保温拆除与新保温安装", desc: "脚手架登高作业,保温棉拆除与更换", rows: [
|
{ name: "吴海涛", org: "设备科", post: "维修班长" },
|
||||||
{ hazard: "登高坠落", cons: "脚手架作业人员坠落伤害", l: 3, s: 3, safe: "脚手架验收合格挂牌;全程系挂双钩安全带", sug: "下方设警戒区,禁止交叉作业" },
|
{ name: "陈国栋", org: "一车间", post: "班长" },
|
||||||
{ hazard: "保温纤维粉尘吸入与皮肤刺激", cons: "硅酸铝纤维引起呼吸道与皮肤职业健康危害", l: 3, s: 2, safe: "佩戴防尘口罩、护目镜与防护服;湿法拆除降尘", sug: "作业后更衣清洗,废料袋装化处置" },
|
];
|
||||||
] },
|
const open = ref(false);
|
||||||
{ step: "步骤3:DCS 参数修改与联锁测试", desc: "报警 / 联锁值上调,联锁传动试验", rows: [
|
const q = ref("");
|
||||||
{ hazard: "参数误改导致报警 / 联锁失效", cons: "运行装置失去超温保护,可能导致超温失控", l: 2, s: 5, safe: "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对", sug: "联锁测试全覆盖并留存记录;修改期间工艺加强监控" },
|
const sels = ref<string[]>(["张工艺", "王仪表"]);
|
||||||
] },
|
const list = computed(() => ANALYSIS_POOL.filter((x) => !q.value || x.name.includes(q.value)));
|
||||||
{ step: "步骤4:升温投用与验证", desc: "按新版操作规程升温至 90℃ 投用", rows: [
|
const toggle = (n: string) => {
|
||||||
{ hazard: "升温过快导致冲料", cons: "釜压骤升,法兰泄漏或安全阀起跳", l: 2, s: 4, safe: "按 5℃/10min 梯度升温;首批投用工艺、仪表、设备三方现场值守", sug: "首 3 批加密温度 / 压力巡检,异常立即降回 80℃ 运行" },
|
sels.value = sels.value.includes(n) ? sels.value.filter((x) => x !== n) : [...sels.value, n];
|
||||||
] },
|
};
|
||||||
]);
|
const rrOf = (l: number, s: number) => {
|
||||||
|
const v = l * s;
|
||||||
const info = ref("R-101 缩合反应釜反应温度由 80℃ 提升至 90℃、保温时间由 8h 缩短至 4h。实施作业包括:釜体保温更换(硅酸铝纤维毡)、DCS 温度报警 / 联锁值上调(高报 85→95℃、联锁 90→100℃)与联锁测试、操作规程修订与培训。作业期间装置保持运行,涉及登高、保温纤维、DCS 参数修改等作业内容。");
|
if (v >= 15) return { t: `重大 (${v})`, c: "bg-red-500 text-white" };
|
||||||
const toRec = (step: string, row: any): any => ({
|
if (v >= 10) return { t: `较大 (${v})`, c: "bg-[#F5A623] text-white" };
|
||||||
item: row.hazard || "(未命名危害因素)",
|
if (v >= 5) return { t: `一般 (${v})`, c: "bg-amber-300 text-amber-900" };
|
||||||
scene: `${step}:${row.hazard},可能导致:${row.cons}`,
|
return { t: `低 (${v})`, c: "bg-emerald-500 text-white" };
|
||||||
inherent: inherentOf(row.l, row.s),
|
}
|
||||||
existing: row.safe,
|
|
||||||
suggest: row.sug,
|
const steps = ref([
|
||||||
residual: lowerOf(inherentOf(row.l, row.s)),
|
{ step: "步骤1:作业准备与票证办理", desc: "作业方案交底、作业许可办理、能量状态确认", rows: [
|
||||||
source: "JSA",
|
{ hazard: "高温介质 / 蒸汽意外释放", cons: "隔离确认不到位导致人员灼烫", l: 2, s: 4, safe: "作业前确认釜体温度 ≤40℃、管线泄压排空;办理作业票证并现场交底", sug: "隔离点挂牌上锁(LOTO),作业前双人确认" },
|
||||||
});
|
] },
|
||||||
|
{ step: "步骤2:旧保温拆除与新保温安装", desc: "脚手架登高作业,保温棉拆除与更换", rows: [
|
||||||
</script>
|
{ hazard: "登高坠落", cons: "脚手架作业人员坠落伤害", l: 3, s: 3, safe: "脚手架验收合格挂牌;全程系挂双钩安全带", sug: "下方设警戒区,禁止交叉作业" },
|
||||||
|
{ hazard: "保温纤维粉尘吸入与皮肤刺激", cons: "硅酸铝纤维引起呼吸道与皮肤职业健康危害", l: 3, s: 2, safe: "佩戴防尘口罩、护目镜与防护服;湿法拆除降尘", sug: "作业后更衣清洗,废料袋装化处置" },
|
||||||
<template>
|
] },
|
||||||
<div class="space-y-3">
|
{ step: "步骤3:DCS 参数修改与联锁测试", desc: "报警 / 联锁值上调,联锁传动试验", rows: [
|
||||||
<div
|
{ hazard: "参数误改导致报警 / 联锁失效", cons: "运行装置失去超温保护,可能导致超温失控", l: 2, s: 5, safe: "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对", sug: "联锁测试全覆盖并留存记录;修改期间工艺加强监控" },
|
||||||
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
|
] },
|
||||||
:style="{
|
{ step: "步骤4:升温投用与验证", desc: "按新版操作规程升温至 90℃ 投用", rows: [
|
||||||
'--theme-color': themeStore.themeColor
|
{ hazard: "升温过快导致冲料", cons: "釜压骤升,法兰泄漏或安全阀起跳", l: 2, s: 4, safe: "按 5℃/10min 梯度升温;首批投用工艺、仪表、设备三方现场值守", sug: "首 3 批加密温度 / 压力巡检,异常立即降回 80℃ 运行" },
|
||||||
}"
|
] },
|
||||||
>
|
]);
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="emit('back')">
|
|
||||||
<Icon icon="basil:arrow-left-outline" class="size-18px" />
|
const info = ref("R-101 缩合反应釜反应温度由 80℃ 提升至 90℃、保温时间由 8h 缩短至 4h。实施作业包括:釜体保温更换(硅酸铝纤维毡)、DCS 温度报警 / 联锁值上调(高报 85→95℃、联锁 90→100℃)与联锁测试、操作规程修订与培训。作业期间装置保持运行,涉及登高、保温纤维、DCS 参数修改等作业内容。");
|
||||||
返回工具列表
|
const toRec = (step: string, row: any): any => ({
|
||||||
</NButton>
|
item: row.hazard || "(未命名危害因素)",
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
scene: `${step}:${row.hazard},可能导致:${row.cons}`,
|
||||||
<span class="text-sm font-bold text-[var(--theme-color)]">JSA 分析插件</span>
|
inherent: inherentOf(row.l, row.s),
|
||||||
<NTag size="small" type="info">插件</NTag>
|
existing: row.safe,
|
||||||
<NTag size="small">MOC 变更:缩合反应温度及时间优化(演示)</NTag>
|
suggest: row.sug,
|
||||||
<span class="text-[11px] text-slate-500">{{`LS 矩阵 · ${steps.length} 个作业步骤 · ${recTotal} 条危害因素记录`}}</span>
|
residual: lowerOf(inherentOf(row.l, row.s)),
|
||||||
<div class="relative">
|
source: "JSA",
|
||||||
<NButton size="small" ghost type="primary" class="text-[11px]" @click="open = !open">
|
});
|
||||||
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ sels.length }} 人
|
|
||||||
</NButton>
|
</script>
|
||||||
<template v-if="open">
|
|
||||||
<div class="fixed inset-0 z-10" @click="open = false"></div>
|
<template>
|
||||||
<div class="absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl">
|
<div class="space-y-3">
|
||||||
<div class="mb-1.5 flex items-center justify-between">
|
<div
|
||||||
<span class="text-xs font-semibold text-slate-700">选择分析组成员(岗位人员库)</span>
|
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
|
||||||
<span class="text-[11px] text-slate-400">已选 {{ sels.length }} 人</span>
|
:style="{
|
||||||
</div>
|
'--theme-color': themeStore.themeColor
|
||||||
<NInput v-model:value="q" size="small" placeholder="搜索人名…" class="mb-1.5 text-xs" />
|
}"
|
||||||
<div class="max-h-48 space-y-0.5 overflow-y-auto">
|
>
|
||||||
<div v-for="x in list" :key="x.name" @click="toggle(x.name)"
|
<NButton size="small" ghost type="primary" class="text-xs" @click="emit('back')">
|
||||||
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
<Icon icon="basil:arrow-left-outline" class="size-18px" />
|
||||||
:class="sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
|
返回工具列表
|
||||||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
|
</NButton>
|
||||||
:class="sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ sels.includes(x.name) ? "✓" : "" }}</span>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
||||||
<span class="font-medium text-slate-800">{{ x.name }}</span>
|
<span class="text-sm font-bold text-[var(--theme-color)]">JSA 分析插件</span>
|
||||||
<span class="text-[11px] text-slate-400">{{ x.org }} · {{ x.post }}</span>
|
<NTag size="small" type="info">插件</NTag>
|
||||||
<NTag v-if="sels[0] === x.name" size="small" type="primary" class="ml-auto">组长</NTag>
|
<NTag size="small">MOC 变更:缩合反应温度及时间优化(演示)</NTag>
|
||||||
</div>
|
<span class="text-[11px] text-slate-500">{{`LS 矩阵 · ${steps.length} 个作业步骤 · ${recTotal} 条危害因素记录`}}</span>
|
||||||
</div>
|
<div class="relative">
|
||||||
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
|
<NButton size="small" ghost type="primary" class="text-[11px]" @click="open = !open">
|
||||||
首位选择者默认为组长;成员为选择式(非手写),参与记录自动归属到人,用于「参与风险分析次数」统计采集。
|
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ sels.length }} 人
|
||||||
</div>
|
</NButton>
|
||||||
</div>
|
<template v-if="open">
|
||||||
</template>
|
<div class="fixed inset-0 z-10" @click="open = false"></div>
|
||||||
</div>
|
<div class="absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl">
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div class="mb-1.5 flex items-center justify-between">
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="exportAll">
|
<span class="text-xs font-semibold text-slate-700">选择分析组成员(岗位人员库)</span>
|
||||||
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
|
<span class="text-[11px] text-slate-400">已选 {{ sels.length }} 人</span>
|
||||||
</NButton>
|
</div>
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="message.success('JSA 分析报告已生成(Word/PDF)')">
|
<NInput v-model:value="q" size="small" placeholder="搜索人名…" class="mb-1.5 text-xs" />
|
||||||
<Icon icon="material-symbols:download" class="size-14px" /> 生成报告
|
<div class="max-h-48 space-y-0.5 overflow-y-auto">
|
||||||
</NButton>
|
<div v-for="x in list" :key="x.name" @click="toggle(x.name)"
|
||||||
<NButton size="small" type="primary" class="text-xs" :disabled="analyzing" @click="runAi">
|
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
||||||
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
:class="sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
|
||||||
<Icon v-else icon="lucide:sparkles" class="size-12px" />
|
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
|
||||||
AI 分析危害原因
|
:class="sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ sels.includes(x.name) ? "✓" : "" }}</span>
|
||||||
</NButton>
|
<span class="font-medium text-slate-800">{{ x.name }}</span>
|
||||||
</div>
|
<span class="text-[11px] text-slate-400">{{ x.org }} · {{ x.post }}</span>
|
||||||
</div>
|
<NTag v-if="sels[0] === x.name" size="small" type="primary" class="ml-auto">组长</NTag>
|
||||||
<div
|
</div>
|
||||||
class="space-y-3"
|
</div>
|
||||||
:style="{
|
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
|
||||||
'--theme-color': themeStore.themeColor,
|
首位选择者默认为组长;成员为选择式(非手写),参与记录自动归属到人,用于「参与风险分析次数」统计采集。
|
||||||
}"
|
</div>
|
||||||
>
|
</div>
|
||||||
<!-- 作业信息 -->
|
</template>
|
||||||
<div class="rounded-lg border bg-white p-3">
|
</div>
|
||||||
<div class="mb-1.5 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
<span class="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--theme-color)] text-[10px] text-white">1</span>
|
<NButton size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="exportAll">
|
||||||
作业信息
|
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
|
||||||
<span class="ml-auto text-[11px] font-normal text-slate-400">作业名称:变更实施作业(自动生成,可修改)</span>
|
</NButton>
|
||||||
</div>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="message.success('JSA 分析报告已生成(Word/PDF)')">
|
||||||
<p class="rounded-lg bg-slate-50 p-2.5 text-xs leading-relaxed text-slate-600">{{ info }}</p>
|
<Icon icon="material-symbols:download" class="size-14px" /> 生成报告
|
||||||
</div>
|
</NButton>
|
||||||
<!-- 作业活动分析记录 -->
|
<NButton size="small" type="primary" class="text-xs" :disabled="analyzing" @click="runAi">
|
||||||
<div class="bg-white">
|
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
||||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-600 border border-b-0 rounded-tl-lg rounded-tr-lg">
|
<Icon v-else icon="lucide:sparkles" class="size-12px" />
|
||||||
<span class="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--theme-color)] text-[10px] text-white">2</span>
|
AI 分析危害原因
|
||||||
作业活动分析记录
|
</NButton>
|
||||||
<NButton size="small" ghost type="primary" class="text-xs ml-2" @click="addRow">
|
</div>
|
||||||
<Icon icon="ic:round-plus" class="size-14px" /> 新增记录(末步骤)
|
</div>
|
||||||
</NButton>
|
<div
|
||||||
<span class="ml-auto text-[11px] font-normal text-slate-400">单元格点击即可修改;L / S 取值 1-5,RR 实时重算;「入表」将本条选入风险分析记录表</span>
|
class="space-y-3"
|
||||||
</div>
|
:style="{
|
||||||
<table class="w-full min-w-[900px] border-collapse text-xs">
|
'--theme-color': themeStore.themeColor,
|
||||||
<thead>
|
}"
|
||||||
<tr>
|
>
|
||||||
<th :class="TH" class="w-40">作业步骤</th>
|
<!-- 作业信息 -->
|
||||||
<th :class="TH">危害因素</th>
|
<div class="rounded-lg border bg-white p-3">
|
||||||
<th :class="TH">后果</th>
|
<div class="mb-1.5 flex items-center gap-2 text-xs font-semibold text-slate-600">
|
||||||
<th :class="TH" class="w-8">L</th>
|
<span class="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--theme-color)] text-[10px] text-white">1</span>
|
||||||
<th :class="TH" class="w-8">S</th>
|
作业信息
|
||||||
<th :class="TH" class="w-20">RR</th>
|
<span class="ml-auto text-[11px] font-normal text-slate-400">作业名称:变更实施作业(自动生成,可修改)</span>
|
||||||
<th :class="TH">安全措施</th>
|
</div>
|
||||||
<th :class="TH">建议措施</th>
|
<p class="rounded-lg bg-slate-50 p-2.5 text-xs leading-relaxed text-slate-600">{{ info }}</p>
|
||||||
<th :class="TH" class="w-16">操作</th>
|
</div>
|
||||||
</tr>
|
<!-- 作业活动分析记录 -->
|
||||||
</thead>
|
<div class="bg-white">
|
||||||
<tbody>
|
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-600 border border-b-0 rounded-tl-lg rounded-tr-lg">
|
||||||
<template v-for="(s, si) in steps" :key="si">
|
<span class="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--theme-color)] text-[10px] text-white">2</span>
|
||||||
<tr v-for="(row, ri) in s.rows" :key="`${si}-${ri}`">
|
作业活动分析记录
|
||||||
<td v-if="ri === 0" :class="TD" class="bg-slate-50" :rowspan="s.rows.length">
|
<NButton size="small" ghost type="primary" class="text-xs ml-2" @click="addRow">
|
||||||
<div class="px-1 text-xs font-semibold text-slate-700">{{ s.step }}</div>
|
<Icon icon="ic:round-plus" class="size-14px" /> 新增记录(末步骤)
|
||||||
<div class="mt-1 px-1 text-[11px] leading-relaxed text-slate-400">{{ s.desc }}</div>
|
</NButton>
|
||||||
</td>
|
<span class="ml-auto text-[11px] font-normal text-slate-400">单元格点击即可修改;L / S 取值 1-5,RR 实时重算;「入表」将本条选入风险分析记录表</span>
|
||||||
<td :class="TD"><Ed :v="row.hazard" @update="(x) => updRow(si, ri, { hazard: x })" /></td>
|
</div>
|
||||||
<td :class="TD"><Ed :v="row.cons" @update="(x) => updRow(si, ri, { cons: x })" /></td>
|
<table class="w-full min-w-[900px] border-collapse text-xs">
|
||||||
<td :class="TD" class="text-center">
|
<thead>
|
||||||
<input :value="row.l" @change="(e) => updRow(si, ri, { l: clampLS((e.target as HTMLInputElement).value) })"
|
<tr>
|
||||||
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
<th :class="TH" class="w-40">作业步骤</th>
|
||||||
</td>
|
<th :class="TH">危害因素</th>
|
||||||
<td :class="TD" class="text-center">
|
<th :class="TH">后果</th>
|
||||||
<input :value="row.s" @change="(e) => updRow(si, ri, { s: clampLS((e.target as HTMLInputElement).value) })"
|
<th :class="TH" class="w-8">L</th>
|
||||||
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
<th :class="TH" class="w-8">S</th>
|
||||||
</td>
|
<th :class="TH" class="w-20">RR</th>
|
||||||
<td :class="TD" class="text-center">
|
<th :class="TH">安全措施</th>
|
||||||
<span class="inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" :class="rrOf(row.l, row.s).c">{{ rrOf(row.l, row.s).t }}</span>
|
<th :class="TH">建议措施</th>
|
||||||
</td>
|
<th :class="TH" class="w-16">操作</th>
|
||||||
<td :class="TD"><Ed :v="row.safe" @update="(x) => updRow(si, ri, { safe: x })" /></td>
|
</tr>
|
||||||
<td :class="TD"><Ed :v="row.sug" @update="(x) => updRow(si, ri, { sug: x })" /></td>
|
</thead>
|
||||||
<td :class="TD" class="text-center">
|
<tbody>
|
||||||
<div class="flex items-center justify-between px-1 mt-1">
|
<template v-for="(s, si) in steps" :key="si">
|
||||||
<NButton text type="primary" class="mt-1px text-[11px] hover:underline" title="选入风险分析记录表"
|
<tr v-for="(row, ri) in s.rows" :key="`${si}-${ri}`">
|
||||||
@click="emit('export', [toRec(s.step, row)], 'manual')">入表
|
<td v-if="ri === 0" :class="TD" class="bg-slate-50" :rowspan="s.rows.length">
|
||||||
</NButton>
|
<div class="px-1 text-xs font-semibold text-slate-700">{{ s.step }}</div>
|
||||||
<NButton text type="error" title="删除行" @click="delRow(si, ri)">
|
<div class="mt-1 px-1 text-[11px] leading-relaxed text-slate-400">{{ s.desc }}</div>
|
||||||
<Icon icon="ep:delete" class="size-14px" />
|
</td>
|
||||||
</NButton>
|
<td :class="TD"><Ed :v="row.hazard" @update="(x) => updRow(si, ri, { hazard: x })" /></td>
|
||||||
</div>
|
<td :class="TD"><Ed :v="row.cons" @update="(x) => updRow(si, ri, { cons: x })" /></td>
|
||||||
</td>
|
<td :class="TD" class="text-center">
|
||||||
</tr>
|
<input :value="row.l" @change="(e) => updRow(si, ri, { l: clampLS((e.target as HTMLInputElement).value) })"
|
||||||
</template>
|
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
<td :class="TD" class="text-center">
|
||||||
</div>
|
<input :value="row.s" @change="(e) => updRow(si, ri, { s: clampLS((e.target as HTMLInputElement).value) })"
|
||||||
</div>
|
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
||||||
</div>
|
</td>
|
||||||
</template>
|
<td :class="TD" class="text-center">
|
||||||
|
<span class="inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" :class="rrOf(row.l, row.s).c">{{ rrOf(row.l, row.s).t }}</span>
|
||||||
|
</td>
|
||||||
|
<td :class="TD"><Ed :v="row.safe" @update="(x) => updRow(si, ri, { safe: x })" /></td>
|
||||||
|
<td :class="TD"><Ed :v="row.sug" @update="(x) => updRow(si, ri, { sug: x })" /></td>
|
||||||
|
<td :class="TD" class="text-center">
|
||||||
|
<div class="flex items-center justify-between px-1 mt-1">
|
||||||
|
<NButton text type="primary" class="mt-1px text-[11px] hover:underline" title="选入风险分析记录表"
|
||||||
|
@click="emit('export', [toRec(s.step, row)], 'manual')">入表
|
||||||
|
</NButton>
|
||||||
|
<NButton text type="error" title="删除行" @click="delRow(si, ri)">
|
||||||
|
<Icon icon="ep:delete" class="size-14px" />
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -1,109 +1,111 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useMessage } from "naive-ui";
|
import { useMessage } from "naive-ui";
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
back: [];
|
back: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const RISK_CHECKS_INIT = [
|
const RISK_CHECKS_INIT = [
|
||||||
{
|
{
|
||||||
id: 1, group: "安全健康", ans: "是",
|
id: 1, group: "安全健康", ans: "是",
|
||||||
q: "是否存在任何可能导致安全问题的化学反应?检查化学反应禁忌表。",
|
q: "是否存在任何可能导致安全问题的化学反应?检查化学反应禁忌表。",
|
||||||
risk: "反应温度从80℃升至90℃,反应速率加快,放热更集中,可能引发反应失控或副反应,导致超温超压。",
|
risk: "反应温度从80℃升至90℃,反应速率加快,放热更集中,可能引发反应失控或副反应,导致超温超压。",
|
||||||
measure: "1. 进行反应量热试验或查阅反应热数据,确认90℃下的放热速率和绝热温升;2. 评估现有冷却系统能力是否满足最大放热需求;3. 更新化学反应风险评估报告;4. 验证反应釜搅拌、冷却系统完好。",
|
measure: "1. 进行反应量热试验或查阅反应热数据,确认90℃下的放热速率和绝热温升;2. 评估现有冷却系统能力是否满足最大放热需求;3. 更新化学反应风险评估报告;4. 验证反应釜搅拌、冷却系统完好。",
|
||||||
},
|
},
|
||||||
{ id: 2, group: "安全健康", ans: "否", q: "是否存在任何易燃易爆化学物质或粉尘?" },
|
{ id: 2, group: "安全健康", ans: "否", q: "是否存在任何易燃易爆化学物质或粉尘?" },
|
||||||
{
|
{
|
||||||
id: 3, group: "安全健康", ans: "是",
|
id: 3, group: "安全健康", ans: "是",
|
||||||
q: "是否存在任何有危险的原材料流速、成分或温度条件变更?",
|
q: "是否存在任何有危险的原材料流速、成分或温度条件变更?",
|
||||||
risk: "反应温度条件从80±2℃变更至90±2℃,接近设备设计温度上限100℃,可能造成副反应增加或杂质生成。",
|
risk: "反应温度条件从80±2℃变更至90±2℃,接近设备设计温度上限100℃,可能造成副反应增加或杂质生成。",
|
||||||
measure: "1. 通过小试实验验证90℃下的副反应种类及含量,确保产品杂质合格;2. 确认原料A和B在此温度下的稳定性;3. 将温度控制精度要求写入操作参数表。",
|
measure: "1. 通过小试实验验证90℃下的副反应种类及含量,确保产品杂质合格;2. 确认原料A和B在此温度下的稳定性;3. 将温度控制精度要求写入操作参数表。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4, group: "安全健康", ans: "是",
|
id: 4, group: "安全健康", ans: "是",
|
||||||
q: "是否会因加热、冷却或精确的温度控制失控而导致安全问题?",
|
q: "是否会因加热、冷却或精确的温度控制失控而导致安全问题?",
|
||||||
risk: "温度控制失效(如加热阀门卡死、冷却中断)可能使实际温度超过100℃,导致设备超温、压力升高或反应失控。",
|
risk: "温度控制失效(如加热阀门卡死、冷却中断)可能使实际温度超过100℃,导致设备超温、压力升高或反应失控。",
|
||||||
measure: "1. 调试并确认DCS温度高报警(95℃)和高高报警联锁(100℃)功能正常;2. 执行联锁回路功能测试(温度变送器、DCS卡件、电磁阀、切断阀);3. 确认加热介质切断阀为故障安全型。",
|
measure: "1. 调试并确认DCS温度高报警(95℃)和高高报警联锁(100℃)功能正常;2. 执行联锁回路功能测试(温度变送器、DCS卡件、电磁阀、切断阀);3. 确认加热介质切断阀为故障安全型。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5, group: "安全健康", ans: "是",
|
id: 5, group: "安全健康", ans: "是",
|
||||||
q: "是否具有为安全操作所必需的仪表、控制、紧急制动、开关或报警?",
|
q: "是否具有为安全操作所必需的仪表、控制、紧急制动、开关或报警?",
|
||||||
risk: "变更后温度报警值上调,现有温度仪表量程可能不足或精度不够,影响安全操作。",
|
risk: "变更后温度报警值上调,现有温度仪表量程可能不足或精度不够,影响安全操作。",
|
||||||
measure: "1. 检查温度变送器量程是否覆盖0~100℃,精度是否优于0.5级;2. 确认DCS报警值设定正确且已组态;3. 在DCS画面中明显标注新报警值。",
|
measure: "1. 检查温度变送器量程是否覆盖0~100℃,精度是否优于0.5级;2. 确认DCS报警值设定正确且已组态;3. 在DCS画面中明显标注新报警值。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6, group: "工艺与操作", ans: "是",
|
id: 6, group: "工艺与操作", ans: "是",
|
||||||
q: "变更后是否需要对操作人员进行专门培训?",
|
q: "变更后是否需要对操作人员进行专门培训?",
|
||||||
risk: "新工况(90℃ / 4小时)与原操作习惯差异较大,误操作可能引发超温或产品质量波动。",
|
risk: "新工况(90℃ / 4小时)与原操作习惯差异较大,误操作可能引发超温或产品质量波动。",
|
||||||
measure: "1. 修订操作规程并组织专项培训考核;2. 投用首周实行双人复核操作;3. 关键参数调整权限上收至班长。",
|
measure: "1. 修订操作规程并组织专项培训考核;2. 投用首周实行双人复核操作;3. 关键参数调整权限上收至班长。",
|
||||||
},
|
},
|
||||||
{ id: 7, group: "工艺与操作", ans: "否", q: "是否涉及应急预案或消防设施的调整?" },
|
{ id: 7, group: "工艺与操作", ans: "否", q: "是否涉及应急预案或消防设施的调整?" },
|
||||||
];
|
];
|
||||||
const pluginDone = ref<Record<string, number>>({});
|
// 说明:本页面暂无「AI 预填检查表」交互按钮,检查项为静态示例数据,保持现状;
|
||||||
const riskChecks = ref<any[]>(RISK_CHECKS_INIT.map((x) => ({ ...x })));
|
// 如需接入可调用 aiRiskCheck(content, rows)(src/service/api/ai.ts,耗时约 1~3 分钟,需处理 42901 限流重试)。
|
||||||
const riskGroups = computed(() => [...new Set(riskChecks.value.map((x) => x.group))]);
|
const pluginDone = ref<Record<string, number>>({});
|
||||||
const riskFilter = ref<string>("全部");
|
const riskChecks = ref<any[]>(RISK_CHECKS_INIT.map((x) => ({ ...x })));
|
||||||
const onBack = () => {
|
const riskGroups = computed(() => [...new Set(riskChecks.value.map((x) => x.group))]);
|
||||||
pluginDone.value = { ...pluginDone.value, RISK_CHECK: riskChecks.value.filter((x) => x.ans === '是').length };
|
const riskFilter = ref<string>("全部");
|
||||||
emit("back");
|
const onBack = () => {
|
||||||
}
|
pluginDone.value = { ...pluginDone.value, RISK_CHECK: riskChecks.value.filter((x) => x.ans === '是').length };
|
||||||
const save = () => {
|
emit("back");
|
||||||
pluginDone.value = { ...pluginDone.value, RISK_CHECK: riskChecks.value.filter((x) => x.ans === '是').length };
|
}
|
||||||
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
const save = () => {
|
||||||
};
|
pluginDone.value = { ...pluginDone.value, RISK_CHECK: riskChecks.value.filter((x) => x.ans === '是').length };
|
||||||
</script>
|
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
||||||
|
};
|
||||||
<template>
|
</script>
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<template>
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="onBack">
|
<div class="space-y-4">
|
||||||
<Icon icon="basil:arrow-left-outline" class="size-18px" /> 返回工具列表
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
</NButton>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="onBack">
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
<Icon icon="basil:arrow-left-outline" class="size-18px" /> 返回工具列表
|
||||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">变更风险检查表(通用)</span>
|
</NButton>
|
||||||
<NTag size="small" type="info">插件</NTag>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
||||||
<div class="flex items-center gap-3">
|
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">变更风险检查表(通用)</span>
|
||||||
<NRadioGroup v-model:value="riskFilter" name="radiogroup">
|
<NTag size="small" type="info">插件</NTag>
|
||||||
<NRadio v-for="song in [{label:'全部',value:'全部'},{label:'仅看「是」',value:'是'}]" :key="song.value" :value="song.value">
|
<div class="flex items-center gap-3">
|
||||||
{{ song.label }}
|
<NRadioGroup v-model:value="riskFilter" name="radiogroup">
|
||||||
</NRadio>
|
<NRadio v-for="song in [{label:'全部',value:'全部'},{label:'仅看「是」',value:'是'}]" :key="song.value" :value="song.value">
|
||||||
</NRadioGroup>
|
{{ song.label }}
|
||||||
</div>
|
</NRadio>
|
||||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
|
</NRadioGroup>
|
||||||
✨ AI 生成 · 需人工确认
|
</div>
|
||||||
</span>
|
<span class="ml-auto inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
|
||||||
</div>
|
✨ AI 生成 · 需人工确认
|
||||||
<p class="text-[11px] text-slate-400">逐项判定「是 / 否 / 不涉及」;判定为「是」的检查项输出风险描述与管控措施,完成后点「返回工具列表」回显完成项数</p>
|
</span>
|
||||||
<div v-for="g in riskGroups" :key="g" class="overflow-hidden rounded-lg border">
|
</div>
|
||||||
<div class="bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700">{{ g }}</div>
|
<p class="text-[11px] text-slate-400">逐项判定「是 / 否 / 不涉及」;判定为「是」的检查项输出风险描述与管控措施,完成后点「返回工具列表」回显完成项数</p>
|
||||||
<div v-for="item in riskChecks.filter((x) => x.group === g && (riskFilter === '全部' || x.ans === '是'))" :key="item.id"
|
<div v-for="g in riskGroups" :key="g" class="overflow-hidden rounded-lg border">
|
||||||
class="grid gap-3 border-t px-3 py-3 grid-cols-[1.1fr_1fr_1fr]">
|
<div class="bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700">{{ g }}</div>
|
||||||
<div>
|
<div v-for="item in riskChecks.filter((x) => x.group === g && (riskFilter === '全部' || x.ans === '是'))" :key="item.id"
|
||||||
<div class="text-sm text-slate-700">{{ item.q }}</div>
|
class="grid gap-3 border-t px-3 py-3 grid-cols-[1.1fr_1fr_1fr]">
|
||||||
<div class="mt-2 flex items-center gap-4">
|
<div>
|
||||||
<span v-for="a in ['是', '否', 'NA']" :key="a">
|
<div class="text-sm text-slate-700">{{ item.q }}</div>
|
||||||
<NRadio
|
<div class="mt-2 flex items-center gap-4">
|
||||||
:checked="item.ans === a"
|
<span v-for="a in ['是', '否', 'NA']" :key="a">
|
||||||
@change="riskChecks = riskChecks.map((x) => (x.id === item.id ? { ...x, ans: a } : x))"
|
<NRadio
|
||||||
>
|
:checked="item.ans === a"
|
||||||
{{ a }}
|
@change="riskChecks = riskChecks.map((x) => (x.id === item.id ? { ...x, ans: a } : x))"
|
||||||
</NRadio>
|
>
|
||||||
</span>
|
{{ a }}
|
||||||
</div>
|
</NRadio>
|
||||||
</div>
|
</span>
|
||||||
<template v-if="item.ans === '是'">
|
</div>
|
||||||
<div class="rounded-lg bg-slate-50 p-2.5 text-xs leading-relaxed text-slate-600">
|
</div>
|
||||||
<span class="font-medium text-slate-500">风险描述:</span>{{ item.risk }}
|
<template v-if="item.ans === '是'">
|
||||||
</div>
|
<div class="rounded-lg bg-slate-50 p-2.5 text-xs leading-relaxed text-slate-600">
|
||||||
<div class="rounded-lg bg-slate-50 p-2.5 text-xs leading-relaxed text-slate-600">
|
<span class="font-medium text-slate-500">风险描述:</span>{{ item.risk }}
|
||||||
<span class="font-medium text-slate-500">管控措施:</span>{{ item.measure }}
|
</div>
|
||||||
</div>
|
<div class="rounded-lg bg-slate-50 p-2.5 text-xs leading-relaxed text-slate-600">
|
||||||
</template>
|
<span class="font-medium text-slate-500">管控措施:</span>{{ item.measure }}
|
||||||
<div v-else class="text-xs text-slate-300 self-center col-span-2">(回答为「{{ item.ans }}」,无风险描述)</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
<div v-else class="text-xs text-slate-300 self-center col-span-2">(回答为「{{ item.ans }}」,无风险描述)</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -1,237 +1,273 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useMessage } from "naive-ui";
|
import { useMessage } from "naive-ui";
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import Ed from "./Ed.vue";
|
import { AiError, aiSclAnalyze } from '@/service/api/ai';
|
||||||
|
import Ed from "./Ed.vue";
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const message = useMessage();
|
const themeStore = useThemeStore();
|
||||||
const emit = defineEmits<{
|
const message = useMessage();
|
||||||
back: [];
|
const emit = defineEmits<{
|
||||||
done: [records: number];
|
back: [];
|
||||||
export: [items: any[], mode: "manual" | "ai"];
|
done: [records: number];
|
||||||
save: [];
|
export: [items: any[], mode: "manual" | "ai"];
|
||||||
}>();
|
save: [];
|
||||||
const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
|
}>();
|
||||||
return lv === "高" ? "中" : "低";
|
const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
|
||||||
}
|
return lv === "高" ? "中" : "低";
|
||||||
const inherentOf = (l: number, s: number): "高" | "中" | "低" => {
|
}
|
||||||
const v = l * s;
|
const inherentOf = (l: number, s: number): "高" | "中" | "低" => {
|
||||||
return v >= 10 ? "高" : v >= 5 ? "中" : "低";
|
const v = l * s;
|
||||||
}
|
return v >= 10 ? "高" : v >= 5 ? "中" : "低";
|
||||||
const toRec = (row: any): any => ({
|
}
|
||||||
item: row.item || "(未命名检查项)",
|
const toRec = (row: any): any => ({
|
||||||
scene: `标准:${row.std};不符合可能导致:${row.cons}`,
|
item: row.item || "(未命名检查项)",
|
||||||
inherent: inherentOf(row.l, row.s),
|
scene: `标准:${row.std};不符合可能导致:${row.cons}`,
|
||||||
existing: row.ctrl,
|
inherent: inherentOf(row.l, row.s),
|
||||||
suggest: row.sug,
|
existing: row.ctrl,
|
||||||
residual: lowerOf(inherentOf(row.l, row.s)),
|
suggest: row.sug,
|
||||||
source: "SCL",
|
residual: lowerOf(inherentOf(row.l, row.s)),
|
||||||
});
|
source: "SCL",
|
||||||
|
});
|
||||||
const gi = ref(0);
|
|
||||||
const cur = computed(() => groups.value[gi.value]);
|
const gi = ref(0);
|
||||||
const recTotal = computed(() => groups.value.reduce((a, g) => a + g.rows.length, 0));
|
const cur = computed(() => groups.value[gi.value]);
|
||||||
|
const recTotal = computed(() => groups.value.reduce((a, g) => a + g.rows.length, 0));
|
||||||
const updRow = (ri: number, patch: Partial<any>) => {
|
|
||||||
const row = cur.value.rows[ri];
|
const updRow = (ri: number, patch: Partial<any>) => {
|
||||||
if (row) Object.assign(row, patch);
|
const row = cur.value.rows[ri];
|
||||||
};
|
if (row) Object.assign(row, patch);
|
||||||
const clampLS = (v: string) => Math.max(1, Math.min(5, +v || 1));
|
};
|
||||||
const addRow = () => cur.value.rows.push({ item: "", std: "", cons: "", l: 2, s: 3, ctrl: "", sug: "" });
|
const clampLS = (v: string) => Math.max(1, Math.min(5, +v || 1));
|
||||||
const delRow = (ri: number) => cur.value.rows.splice(ri, 1);
|
const addRow = () => cur.value.rows.push({ item: "", std: "", cons: "", l: 2, s: 3, ctrl: "", sug: "" });
|
||||||
const exportAll = () => emit("export", groups.value.flatMap((g) => g.rows.map(toRec)), "ai");
|
const delRow = (ri: number) => cur.value.rows.splice(ri, 1);
|
||||||
|
const exportAll = () => emit("export", groups.value.flatMap((g) => g.rows.map(toRec)), "ai");
|
||||||
const save = () => {
|
|
||||||
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
const save = () => {
|
||||||
};
|
message.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
|
||||||
const SCL_INFO = {
|
};
|
||||||
dev: "R-101 缩合反应釜",
|
|
||||||
mat: "缩合反应体系(有机溶剂 / 反应物料)",
|
const analyzing = ref(false);
|
||||||
param: "设计温度:-10/120 ℃,设计压力:0.6 MPa;变更后操作温度:90±2 ℃(原 80±2 ℃),常压",
|
const runAi = async () => {
|
||||||
};
|
analyzing.value = true;
|
||||||
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
|
try {
|
||||||
const TD = "border px-1.5 py-1 align-top";
|
const res = await aiSclAnalyze({
|
||||||
|
device: { name: SCL_INFO.dev, material_info: SCL_INFO.mat, run_params: SCL_INFO.param },
|
||||||
const ANALYSIS_POOL = [
|
category: cur.value.g,
|
||||||
{ name: "张工艺", org: "一车间", post: "工艺工程师" },
|
change_context: {
|
||||||
{ name: "王仪表", org: "技术部", post: "仪表工程师" },
|
change: "缩合反应温度及时间优化",
|
||||||
{ name: "周设备", org: "设备科", post: "设备工程师" },
|
content: "R-101 缩合反应釜反应温度由 80℃ 提升至 90℃、保温时间由 8h 缩短至 4h,DCS 温度报警 / 联锁值随之上调",
|
||||||
{ name: "刘文静", org: "技术部", post: "工艺员" },
|
},
|
||||||
{ name: "王海燕", org: "安全环保部", post: "安全工程师" },
|
});
|
||||||
{ name: "李主任", org: "一车间", post: "车间主任" },
|
let added = 0;
|
||||||
{ name: "吴海涛", org: "设备科", post: "维修班长" },
|
(res?.groups ?? []).forEach((g) => {
|
||||||
{ name: "陈国栋", org: "一车间", post: "班长" },
|
const rows = (g.rows ?? []).map((r) => ({
|
||||||
];
|
item: r.item ?? "", std: r.std ?? "", cons: r.consequence ?? "", l: r.l ?? 2, s: r.s ?? 3,
|
||||||
const open = ref(false);
|
ctrl: r.controls ?? "", sug: r.suggestions ?? "",
|
||||||
const q = ref("");
|
}));
|
||||||
const sels = ref<string[]>(["张工艺", "王仪表"]);
|
if (!rows.length) return;
|
||||||
const list = computed(() => ANALYSIS_POOL.filter((x) => !q.value || x.name.includes(q.value)));
|
const target = groups.value.find((x) => x.g === g.g);
|
||||||
const toggle = (n: string) => {
|
if (target) target.rows.push(...rows);
|
||||||
sels.value = sels.value.includes(n) ? sels.value.filter((x) => x !== n) : [...sels.value, n];
|
else groups.value.push({ g: g.g || cur.value.g, rows });
|
||||||
};
|
added += rows.length;
|
||||||
const rrOf = (l: number, s: number) => {
|
});
|
||||||
const v = l * s;
|
if (!added) throw new AiError(0, "AI 未返回分析结果");
|
||||||
if (v >= 15) return { t: `重大 (${v})`, c: "bg-red-500 text-white" };
|
message.success(`AI 辅助分析完成:新增 ${added} 项检查记录(支持手动修改)`);
|
||||||
if (v >= 10) return { t: `较大 (${v})`, c: "bg-[#F5A623] text-white" };
|
} catch (e: any) {
|
||||||
if (v >= 5) return { t: `一般 (${v})`, c: "bg-amber-300 text-amber-900" };
|
const msg = e instanceof AiError ? e.message : "AI 服务异常";
|
||||||
return { t: `低 (${v})`, c: "bg-emerald-500 text-white" };
|
window.$message?.warning(`AI 分析失败:${msg},可手动填写`);
|
||||||
}
|
} finally {
|
||||||
|
analyzing.value = false;
|
||||||
const groups = ref([
|
}
|
||||||
{ g: "本体", rows: [
|
};
|
||||||
{ item: "釜体及夹套壁厚", std: "测厚值 ≥ 设计最小壁厚,无超标腐蚀减薄", cons: "壁厚不足导致强度失效、泄漏", l: 1, s: 4, ctrl: "工程控制:定期测厚;管理措施:年度检验计划", sug: "变更后温度提升,复核腐蚀速率评估" },
|
const SCL_INFO = {
|
||||||
{ item: "搅拌器运行状态", std: "电流、振动在正常范围,无异响", cons: "搅拌失效导致传热不均、局部过热", l: 2, s: 3, ctrl: "工程控制:电流在线监视;培训教育:异常处置规程", sug: "投用首周每班记录搅拌电流趋势" },
|
dev: "R-101 缩合反应釜",
|
||||||
] },
|
mat: "缩合反应体系(有机溶剂 / 反应物料)",
|
||||||
{ g: "安全附件", rows: [
|
param: "设计温度:-10/120 ℃,设计压力:0.6 MPa;变更后操作温度:90±2 ℃(原 80±2 ℃),常压",
|
||||||
{ item: "安全阀", std: "校验在有效期内,起跳压力 0.5 MPa,铅封完好", cons: "超压时无法正常泄放", l: 1, s: 5, ctrl: "工程控制:定期校验;管理措施:校验台账管理", sug: "本次温度变更不影响泄放工况,维持原校验周期" },
|
};
|
||||||
{ item: "温度 / 压力仪表", std: "校验合格,现场示值与 DCS 一致", cons: "测量失真导致误判", l: 2, s: 3, ctrl: "管理措施:周期校验;现场与 DCS 比对核查", sug: "变更投用前完成一次示值比对" },
|
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
|
||||||
] },
|
const TD = "border px-1.5 py-1 align-top";
|
||||||
{ g: "DCS 联锁与报警", rows: [
|
|
||||||
{ item: "温度高报 / 联锁设定值", std: "高报 95℃、高高报及联锁 100℃,与变更单一致", cons: "设定错误导致保护失效或误动作", l: 2, s: 5, ctrl: "管理措施:双人复核修改;工程控制:联锁测试", sug: "测试记录归档至变更资料" },
|
const ANALYSIS_POOL = [
|
||||||
{ item: "联锁传动测试", std: "试验动作正确率 100%", cons: "联锁拒动 / 误动", l: 1, s: 5, ctrl: "管理措施:投用前强制测试;培训教育", sug: "纳入 PSSR 必查项" },
|
{ name: "张工艺", org: "一车间", post: "工艺工程师" },
|
||||||
] },
|
{ name: "王仪表", org: "技术部", post: "仪表工程师" },
|
||||||
{ g: "操作规程与应急", rows: [
|
{ name: "周设备", org: "设备科", post: "设备工程师" },
|
||||||
{ item: "新版操作规程", std: "90℃ / 4h 工艺参数已修订发布并完成培训", cons: "沿用旧规程导致误操作", l: 2, s: 3, ctrl: "管理措施:规程受控发放;培训教育:签到考核", sug: "旧版规程现场收回,防止混用" },
|
{ name: "刘文静", org: "技术部", post: "工艺员" },
|
||||||
{ item: "超温应急处置卡", std: "处置卡已更新并放置岗位", cons: "应急处置不当扩大事态", l: 1, s: 4, ctrl: "培训教育:应急演练;个体防护:应急物资就位", sug: "投用前组织一次超温处置桌面演练" },
|
{ name: "王海燕", org: "安全环保部", post: "安全工程师" },
|
||||||
] },
|
{ name: "李主任", org: "一车间", post: "车间主任" },
|
||||||
])
|
{ name: "吴海涛", org: "设备科", post: "维修班长" },
|
||||||
</script>
|
{ name: "陈国栋", org: "一车间", post: "班长" },
|
||||||
|
];
|
||||||
<template>
|
const open = ref(false);
|
||||||
<div class="space-y-3">
|
const q = ref("");
|
||||||
<div
|
const sels = ref<string[]>(["张工艺", "王仪表"]);
|
||||||
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
|
const list = computed(() => ANALYSIS_POOL.filter((x) => !q.value || x.name.includes(q.value)));
|
||||||
:style="{
|
const toggle = (n: string) => {
|
||||||
'--theme-color': themeStore.themeColor
|
sels.value = sels.value.includes(n) ? sels.value.filter((x) => x !== n) : [...sels.value, n];
|
||||||
}"
|
};
|
||||||
>
|
const rrOf = (l: number, s: number) => {
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="emit('back')">
|
const v = l * s;
|
||||||
<Icon icon="basil:arrow-left-outline" class="size-18px" />
|
if (v >= 15) return { t: `重大 (${v})`, c: "bg-red-500 text-white" };
|
||||||
返回工具列表
|
if (v >= 10) return { t: `较大 (${v})`, c: "bg-[#F5A623] text-white" };
|
||||||
</NButton>
|
if (v >= 5) return { t: `一般 (${v})`, c: "bg-amber-300 text-amber-900" };
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
return { t: `低 (${v})`, c: "bg-emerald-500 text-white" };
|
||||||
<span class="text-sm font-bold text-[var(--theme-color)]">SCL 安全检查表插件</span>
|
}
|
||||||
<NTag size="small" type="info">插件</NTag>
|
|
||||||
<NTag size="small">MOC 变更:缩合反应温度及时间优化(演示)</NTag>
|
const groups = ref([
|
||||||
<span class="text-[11px] text-slate-500">{{ `${groups.length} 个检查分类 · ${recTotal} 项检查记录` }}</span>
|
{ g: "本体", rows: [
|
||||||
<div class="relative">
|
{ item: "釜体及夹套壁厚", std: "测厚值 ≥ 设计最小壁厚,无超标腐蚀减薄", cons: "壁厚不足导致强度失效、泄漏", l: 1, s: 4, ctrl: "工程控制:定期测厚;管理措施:年度检验计划", sug: "变更后温度提升,复核腐蚀速率评估" },
|
||||||
<NButton size="small" ghost type="primary" class="text-[11px]" @click="open = !open">
|
{ item: "搅拌器运行状态", std: "电流、振动在正常范围,无异响", cons: "搅拌失效导致传热不均、局部过热", l: 2, s: 3, ctrl: "工程控制:电流在线监视;培训教育:异常处置规程", sug: "投用首周每班记录搅拌电流趋势" },
|
||||||
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ sels.length }} 人
|
] },
|
||||||
</NButton>
|
{ g: "安全附件", rows: [
|
||||||
<template v-if="open">
|
{ item: "安全阀", std: "校验在有效期内,起跳压力 0.5 MPa,铅封完好", cons: "超压时无法正常泄放", l: 1, s: 5, ctrl: "工程控制:定期校验;管理措施:校验台账管理", sug: "本次温度变更不影响泄放工况,维持原校验周期" },
|
||||||
<div class="fixed inset-0 z-10" @click="open = false"></div>
|
{ item: "温度 / 压力仪表", std: "校验合格,现场示值与 DCS 一致", cons: "测量失真导致误判", l: 2, s: 3, ctrl: "管理措施:周期校验;现场与 DCS 比对核查", sug: "变更投用前完成一次示值比对" },
|
||||||
<div class="absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl">
|
] },
|
||||||
<div class="mb-1.5 flex items-center justify-between">
|
{ g: "DCS 联锁与报警", rows: [
|
||||||
<span class="text-xs font-semibold text-slate-700">选择分析组成员(岗位人员库)</span>
|
{ item: "温度高报 / 联锁设定值", std: "高报 95℃、高高报及联锁 100℃,与变更单一致", cons: "设定错误导致保护失效或误动作", l: 2, s: 5, ctrl: "管理措施:双人复核修改;工程控制:联锁测试", sug: "测试记录归档至变更资料" },
|
||||||
<span class="text-[11px] text-slate-400">已选 {{ sels.length }} 人</span>
|
{ item: "联锁传动测试", std: "试验动作正确率 100%", cons: "联锁拒动 / 误动", l: 1, s: 5, ctrl: "管理措施:投用前强制测试;培训教育", sug: "纳入 PSSR 必查项" },
|
||||||
</div>
|
] },
|
||||||
<NInput v-model:value="q" size="small" placeholder="搜索人名…" class="mb-1.5 text-xs" />
|
{ g: "操作规程与应急", rows: [
|
||||||
<div class="max-h-48 space-y-0.5 overflow-y-auto">
|
{ item: "新版操作规程", std: "90℃ / 4h 工艺参数已修订发布并完成培训", cons: "沿用旧规程导致误操作", l: 2, s: 3, ctrl: "管理措施:规程受控发放;培训教育:签到考核", sug: "旧版规程现场收回,防止混用" },
|
||||||
<div v-for="x in list" :key="x.name" @click="toggle(x.name)"
|
{ item: "超温应急处置卡", std: "处置卡已更新并放置岗位", cons: "应急处置不当扩大事态", l: 1, s: 4, ctrl: "培训教育:应急演练;个体防护:应急物资就位", sug: "投用前组织一次超温处置桌面演练" },
|
||||||
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
] },
|
||||||
:class="sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
|
])
|
||||||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
|
</script>
|
||||||
:class="sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ sels.includes(x.name) ? "✓" : "" }}</span>
|
|
||||||
<span class="font-medium text-slate-800">{{ x.name }}</span>
|
<template>
|
||||||
<span class="text-[11px] text-slate-400">{{ x.org }} · {{ x.post }}</span>
|
<div class="space-y-3">
|
||||||
<NTag v-if="sels[0] === x.name" size="small" type="primary" class="ml-auto">组长</NTag>
|
<div
|
||||||
</div>
|
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
|
||||||
</div>
|
:style="{
|
||||||
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
|
'--theme-color': themeStore.themeColor
|
||||||
首位选择者默认为组长;成员为选择式(非手写),参与记录自动归属到人,用于「参与风险分析次数」统计采集。
|
}"
|
||||||
</div>
|
>
|
||||||
</div>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="emit('back')">
|
||||||
</template>
|
<Icon icon="basil:arrow-left-outline" class="size-18px" />
|
||||||
</div>
|
返回工具列表
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
</NButton>
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="exportAll">
|
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
|
||||||
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
|
<span class="text-sm font-bold text-[var(--theme-color)]">SCL 安全检查表插件</span>
|
||||||
</NButton>
|
<NTag size="small" type="info">插件</NTag>
|
||||||
<NButton size="small" ghost type="primary" class="text-xs" @click="message.success('SCL 检查表已导出(Word)')">
|
<NTag size="small">MOC 变更:缩合反应温度及时间优化(演示)</NTag>
|
||||||
<Icon icon="material-symbols:download" class="size-14px" /> 导出 Word
|
<span class="text-[11px] text-slate-500">{{ `${groups.length} 个检查分类 · ${recTotal} 项检查记录` }}</span>
|
||||||
</NButton>
|
<div class="relative">
|
||||||
<NButton size="small" type="primary" class="text-xs" @click="message.success('演示:AI 辅助分析完成,已按变更内容核对检查标准(支持手动修改)')">
|
<NButton size="small" ghost type="primary" class="text-[11px]" @click="open = !open">
|
||||||
<Icon icon="lucide:sparkles" class="size-12px" />
|
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ sels.length }} 人
|
||||||
辅助分析
|
</NButton>
|
||||||
</NButton>
|
<template v-if="open">
|
||||||
</div>
|
<div class="fixed inset-0 z-10" @click="open = false"></div>
|
||||||
</div>
|
<div class="absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl">
|
||||||
<!-- 设备信息头 -->
|
<div class="mb-1.5 flex items-center justify-between">
|
||||||
<div class="grid gap-2 rounded-lg border bg-white p-3 text-xs sm:grid-cols-3">
|
<span class="text-xs font-semibold text-slate-700">选择分析组成员(岗位人员库)</span>
|
||||||
<div><span class="text-slate-400">设备名称:</span><b class="text-slate-700">{{ SCL_INFO.dev }}</b></div>
|
<span class="text-[11px] text-slate-400">已选 {{ sels.length }} 人</span>
|
||||||
<div><span class="text-slate-400">物料信息:</span><b class="text-slate-700">{{ SCL_INFO.mat }}</b></div>
|
</div>
|
||||||
<div class="sm:col-span-3"><span class="text-slate-400">运行参数:</span><span class="text-slate-600">{{ SCL_INFO.param }}</span></div>
|
<NInput v-model:value="q" size="small" placeholder="搜索人名…" class="mb-1.5 text-xs" />
|
||||||
</div>
|
<div class="max-h-48 space-y-0.5 overflow-y-auto">
|
||||||
|
<div v-for="x in list" :key="x.name" @click="toggle(x.name)"
|
||||||
<div
|
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
|
||||||
class="flex gap-3"
|
:class="sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
|
||||||
:style="{'--theme-color':themeStore.themeColor}"
|
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
|
||||||
>
|
:class="sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ sels.includes(x.name) ? "✓" : "" }}</span>
|
||||||
<!-- 左:检查分类 -->
|
<span class="font-medium text-slate-800">{{ x.name }}</span>
|
||||||
<div class="shrink-0 rounded-lg border bg-white p-2 w-44">
|
<span class="text-[11px] text-slate-400">{{ x.org }} · {{ x.post }}</span>
|
||||||
<div v-for="(g, i) in groups" :key="g.g" @click="gi = i"
|
<NTag v-if="sels[0] === x.name" size="small" type="primary" class="ml-auto">组长</NTag>
|
||||||
class="cursor-pointer flex w-full items-center rounded-md px-3 py-1.5 text-left text-xs transition"
|
</div>
|
||||||
:class="gi === i ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'">
|
</div>
|
||||||
<span class="flex-1">{{ g.g }}</span>
|
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
|
||||||
<span class="rounded-full px-1.5 text-[10px]" :class="gi === i ? 'bg-white/25' : 'bg-slate-100 text-slate-400'">{{ g.rows.length }}</span>
|
首位选择者默认为组长;成员为选择式(非手写),参与记录自动归属到人,用于「参与风险分析次数」统计采集。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右:检查表 -->
|
</template>
|
||||||
<div class="min-w-0 flex-1 bg-white">
|
</div>
|
||||||
<div class="flex items-center gap-2 pb-2">
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
<NButton size="small" class="text-xs" @click="addRow">
|
<NButton size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="exportAll">
|
||||||
<Icon icon="ic:round-plus" class="size-14px" />新增检查项
|
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
|
||||||
</NButton>
|
</NButton>
|
||||||
<span class="text-[11px] text-slate-400">单元格点击即可修改;L / S 取值 1-5,风险等级按矩阵实时重算;「入表」将本条选入风险分析记录表</span>
|
<NButton size="small" ghost type="primary" class="text-xs" @click="message.success('SCL 检查表已导出(Word)')">
|
||||||
</div>
|
<Icon icon="material-symbols:download" class="size-14px" /> 导出 Word
|
||||||
<table class="w-full min-w-[900px] border-collapse text-xs">
|
</NButton>
|
||||||
<thead>
|
<NButton size="small" type="primary" class="text-xs" :disabled="analyzing" @click="runAi">
|
||||||
<tr>
|
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
||||||
<th :class="TH">检查项目</th>
|
<Icon v-else icon="lucide:sparkles" class="size-12px" />
|
||||||
<th :class="TH">标准</th>
|
辅助分析
|
||||||
<th :class="TH">不符合标准情况及后果</th>
|
</NButton>
|
||||||
<th :class="TH" class="w-8">L</th>
|
</div>
|
||||||
<th :class="TH" class="w-8">S</th>
|
</div>
|
||||||
<th :class="TH" class="w-20">RR</th>
|
<!-- 设备信息头 -->
|
||||||
<th :class="TH">现有控制措施</th>
|
<div class="grid gap-2 rounded-lg border bg-white p-3 text-xs sm:grid-cols-3">
|
||||||
<th :class="TH">建议措施</th>
|
<div><span class="text-slate-400">设备名称:</span><b class="text-slate-700">{{ SCL_INFO.dev }}</b></div>
|
||||||
<th :class="TH" class="w-16">操作</th>
|
<div><span class="text-slate-400">物料信息:</span><b class="text-slate-700">{{ SCL_INFO.mat }}</b></div>
|
||||||
</tr>
|
<div class="sm:col-span-3"><span class="text-slate-400">运行参数:</span><span class="text-slate-600">{{ SCL_INFO.param }}</span></div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
|
||||||
<tr v-for="(row, ri) in cur.rows" :key="ri">
|
<div
|
||||||
<td :class="TD"><Ed :v="row.item" @update="(x) => updRow(ri, { item: x })" /></td>
|
class="flex gap-3"
|
||||||
<td :class="TD"><Ed :v="row.std" @update="(x) => updRow(ri, { std: x })" /></td>
|
:style="{'--theme-color':themeStore.themeColor}"
|
||||||
<td :class="TD"><Ed :v="row.cons" @update="(x) => updRow(ri, { cons: x })" /></td>
|
>
|
||||||
<td :class="TD" class="text-center">
|
<!-- 左:检查分类 -->
|
||||||
<input :value="row.l" @change="(e) => updRow(ri, { l: clampLS((e.target as HTMLInputElement).value) })"
|
<div class="shrink-0 rounded-lg border bg-white p-2 w-44">
|
||||||
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
<div v-for="(g, i) in groups" :key="g.g" @click="gi = i"
|
||||||
</td>
|
class="cursor-pointer flex w-full items-center rounded-md px-3 py-1.5 text-left text-xs transition"
|
||||||
<td :class="TD" class="text-center">
|
:class="gi === i ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'">
|
||||||
<input :value="row.s" @change="(e) => updRow(ri, { s: clampLS((e.target as HTMLInputElement).value) })"
|
<span class="flex-1">{{ g.g }}</span>
|
||||||
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
<span class="rounded-full px-1.5 text-[10px]" :class="gi === i ? 'bg-white/25' : 'bg-slate-100 text-slate-400'">{{ g.rows.length }}</span>
|
||||||
</td>
|
</div>
|
||||||
<td :class="TD" class="text-center">
|
</div>
|
||||||
<span class="inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" :class="rrOf(row.l, row.s).c">{{ rrOf(row.l, row.s).t }}</span>
|
<!-- 右:检查表 -->
|
||||||
</td>
|
<div class="min-w-0 flex-1 bg-white">
|
||||||
<td :class="TD"><Ed :v="row.ctrl" @update="(x) => updRow(ri, { ctrl: x })" /></td>
|
<div class="flex items-center gap-2 pb-2">
|
||||||
<td :class="TD"><Ed :v="row.sug" @update="(x) => updRow(ri, { sug: x })" /></td>
|
<NButton size="small" class="text-xs" @click="addRow">
|
||||||
<td :class="TD">
|
<Icon icon="ic:round-plus" class="size-14px" />新增检查项
|
||||||
<div class="flex items-center justify-between px-1 mt-1">
|
</NButton>
|
||||||
<NButton text type="primary" class="mt-1px text-[11px] hover:underline" title="选入风险分析记录表"
|
<span class="text-[11px] text-slate-400">单元格点击即可修改;L / S 取值 1-5,风险等级按矩阵实时重算;「入表」将本条选入风险分析记录表</span>
|
||||||
@click="emit('export', [toRec(row)], 'manual')">入表
|
</div>
|
||||||
</NButton>
|
<table class="w-full min-w-[900px] border-collapse text-xs">
|
||||||
<NButton text type="error" title="删除行" @click="delRow(ri)">
|
<thead>
|
||||||
<Icon icon="ep:delete" class="size-14px" />
|
<tr>
|
||||||
</NButton>
|
<th :class="TH">检查项目</th>
|
||||||
</div>
|
<th :class="TH">标准</th>
|
||||||
</td>
|
<th :class="TH">不符合标准情况及后果</th>
|
||||||
</tr>
|
<th :class="TH" class="w-8">L</th>
|
||||||
</tbody>
|
<th :class="TH" class="w-8">S</th>
|
||||||
</table>
|
<th :class="TH" class="w-20">RR</th>
|
||||||
</div>
|
<th :class="TH">现有控制措施</th>
|
||||||
</div>
|
<th :class="TH">建议措施</th>
|
||||||
</div>
|
<th :class="TH" class="w-16">操作</th>
|
||||||
</template>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, ri) in cur.rows" :key="ri">
|
||||||
|
<td :class="TD"><Ed :v="row.item" @update="(x) => updRow(ri, { item: x })" /></td>
|
||||||
|
<td :class="TD"><Ed :v="row.std" @update="(x) => updRow(ri, { std: x })" /></td>
|
||||||
|
<td :class="TD"><Ed :v="row.cons" @update="(x) => updRow(ri, { cons: x })" /></td>
|
||||||
|
<td :class="TD" class="text-center">
|
||||||
|
<input :value="row.l" @change="(e) => updRow(ri, { l: clampLS((e.target as HTMLInputElement).value) })"
|
||||||
|
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
||||||
|
</td>
|
||||||
|
<td :class="TD" class="text-center">
|
||||||
|
<input :value="row.s" @change="(e) => updRow(ri, { s: clampLS((e.target as HTMLInputElement).value) })"
|
||||||
|
class="w-7 rounded border border-transparent text-center hover:border-slate-200" />
|
||||||
|
</td>
|
||||||
|
<td :class="TD" class="text-center">
|
||||||
|
<span class="inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" :class="rrOf(row.l, row.s).c">{{ rrOf(row.l, row.s).t }}</span>
|
||||||
|
</td>
|
||||||
|
<td :class="TD"><Ed :v="row.ctrl" @update="(x) => updRow(ri, { ctrl: x })" /></td>
|
||||||
|
<td :class="TD"><Ed :v="row.sug" @update="(x) => updRow(ri, { sug: x })" /></td>
|
||||||
|
<td :class="TD">
|
||||||
|
<div class="flex items-center justify-between px-1 mt-1">
|
||||||
|
<NButton text type="primary" class="mt-1px text-[11px] hover:underline" title="选入风险分析记录表"
|
||||||
|
@click="emit('export', [toRec(row)], 'manual')">入表
|
||||||
|
</NButton>
|
||||||
|
<NButton text type="error" title="删除行" @click="delRow(ri)">
|
||||||
|
<Icon icon="ep:delete" class="size-14px" />
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -1,212 +1,212 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, h, onMounted } from 'vue';
|
import { ref, computed, h, onMounted } from 'vue';
|
||||||
import { useAppStore } from '@/store/modules/app';
|
import { useAppStore } from '@/store/modules/app';
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { NButton } from 'naive-ui';
|
import { NButton } from 'naive-ui';
|
||||||
import { roleListApi, editRoleApi } from '@/service/api/role';
|
import { roleListApi, editRoleApi } from '@/service/api/role';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||||
|
|
||||||
const loading = ref<boolean>(false);
|
const loading = ref<boolean>(false);
|
||||||
const btnLoading = ref<boolean>(false);
|
const btnLoading = ref<boolean>(false);
|
||||||
const formRef = ref<any>(null);
|
const formRef = ref<any>(null);
|
||||||
const currentRole = ref<{ id: number, intro: string, name: string, perms: string[] }>({
|
const currentRole = ref<{ id: number, intro: string, name: string, perms: string[] }>({
|
||||||
id: 0,
|
id: 0,
|
||||||
intro: '',
|
intro: '',
|
||||||
name: '',
|
name: '',
|
||||||
perms: [],
|
perms: [],
|
||||||
});
|
});
|
||||||
const rules = ref({
|
const rules = ref({
|
||||||
name: { required: true, message: '角色名称不能为空', trigger: ['blur'] }
|
name: { required: true, message: '角色名称不能为空', trigger: ['blur'] }
|
||||||
})
|
})
|
||||||
const roleModal = ref<boolean>(false);
|
const roleModal = ref<boolean>(false);
|
||||||
const permissionModal = ref<boolean>(false);
|
const permissionModal = ref<boolean>(false);
|
||||||
const pagination = ref<{ page: number, pageSize: number, pageCount: number }>({
|
const pagination = ref<{ page: number, pageSize: number, pageCount: number }>({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
})
|
})
|
||||||
const columns = ref([
|
const columns = ref([
|
||||||
{
|
{
|
||||||
title: '序号',
|
title: '序号',
|
||||||
key: 'index',
|
key: 'index',
|
||||||
width: 5,
|
width: 5,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_row: any, index: number) => {
|
render: (_row: any, index: number) => {
|
||||||
return h('span', {}, { default: () => (pagination.value.page - 1) * pagination.value.pageSize + index + 1 })
|
return h('span', {}, { default: () => (pagination.value.page - 1) * pagination.value.pageSize + index + 1 })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '角色名称',
|
title: '角色名称',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
width: 44,
|
width: 44,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 8,
|
width: 8,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (row: any) => {
|
render: (row: any) => {
|
||||||
return h('div', { class: 'flex items-center justify-center gap-3' }, {
|
return h('div', { class: 'flex items-center justify-center gap-3' }, {
|
||||||
default: () => [
|
default: () => [
|
||||||
h(NButton, {
|
h(NButton, {
|
||||||
text: true,
|
text: true,
|
||||||
size: 'tiny',
|
size: 'tiny',
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
onClick: () => handleSetPermission(row)
|
onClick: () => handleSetPermission(row)
|
||||||
}, {
|
}, {
|
||||||
// 渲染按钮文本
|
// 渲染按钮文本
|
||||||
default: () => '设置权限'
|
default: () => '设置权限'
|
||||||
}),
|
}),
|
||||||
h(NButton, {
|
h(NButton, {
|
||||||
text: true,
|
text: true,
|
||||||
size: 'tiny',
|
size: 'tiny',
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
onClick: () => handleEdit(row)
|
onClick: () => handleEdit(row)
|
||||||
}, {
|
}, {
|
||||||
// 渲染按钮文本
|
// 渲染按钮文本
|
||||||
default: () => '编辑'
|
default: () => '编辑'
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const list = ref<{ id: number, name: string, intro: string, perms: string[] }[]>([])
|
const list = ref<{ id: number, name: string, intro: string, perms: string[] }[]>([])
|
||||||
|
|
||||||
// 获取列表
|
// 获取列表
|
||||||
const getList = async () => {
|
const getList = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await roleListApi();
|
const res = await roleListApi();
|
||||||
list.value = res.data;
|
list.value = res.data;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置权限
|
// 设置权限
|
||||||
const handleSetPermission = (row: any) => {
|
const handleSetPermission = (row: any) => {
|
||||||
currentRole.value = JSON.parse(JSON.stringify(row));
|
currentRole.value = JSON.parse(JSON.stringify(row));
|
||||||
permissionModal.value = true;
|
permissionModal.value = true;
|
||||||
}
|
}
|
||||||
// 编辑角色
|
// 编辑角色
|
||||||
const handleEdit = (row: any) => {
|
const handleEdit = (row: any) => {
|
||||||
currentRole.value = JSON.parse(JSON.stringify(row));
|
currentRole.value = JSON.parse(JSON.stringify(row));
|
||||||
roleModal.value = true;
|
roleModal.value = true;
|
||||||
}
|
}
|
||||||
// 保存角色
|
// 保存角色
|
||||||
const saveRole = (e: MouseEvent) => {
|
const saveRole = (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
formRef.value?.validate(async (errors: any) => {
|
formRef.value?.validate(async (errors: any) => {
|
||||||
if (!errors) {
|
if (!errors) {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await editRoleApi(currentRole.value);
|
await editRoleApi(currentRole.value);
|
||||||
const target = list.value.find((item:any) => item.id === currentRole.value.id);
|
const target = list.value.find((item:any) => item.id === currentRole.value.id);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.name = currentRole.value.name;
|
target.name = currentRole.value.name;
|
||||||
}
|
}
|
||||||
window.$message?.success('操作成功')
|
window.$message?.success('操作成功')
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
roleModal.value = false;
|
roleModal.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 保存权限
|
// 保存权限
|
||||||
const savePermission = async () => {
|
const savePermission = async () => {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.$message?.success('操作成功')
|
window.$message?.success('操作成功')
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
permissionModal.value = false;
|
permissionModal.value = false;
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const allRoutes = router.getRoutes();
|
const allRoutes = router.getRoutes();
|
||||||
getList()
|
getList()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NSpace vertical :size="16">
|
<NSpace vertical :size="16">
|
||||||
<!-- 角色管理 -->
|
<!-- 角色管理 -->
|
||||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||||
<div class="border mb-3 py-2 px-3" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
<div class="border mb-3 py-2 px-3" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||||
<NGi span="24 s:24 m:24">
|
<NGi span="24 s:24 m:24">
|
||||||
<div class="h-100% flex items-center gap-2">
|
<div class="h-100% flex items-center gap-2">
|
||||||
<Icon icon="lucide:user" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
<Icon icon="lucide:user" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||||
<p class="text-[16px] font-600">角色管理</p>
|
<p class="text-[16px] font-600">角色管理</p>
|
||||||
</div>
|
</div>
|
||||||
</NGi>
|
</NGi>
|
||||||
</NGrid>
|
</NGrid>
|
||||||
</div>
|
</div>
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<NDataTable
|
<NDataTable
|
||||||
size="small"
|
size="small"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:single-line="false"
|
:single-line="false"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="list"
|
:data="list"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
max-height="calc(100vh - 290px)"
|
max-height="calc(100vh - 290px)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 新增或编辑弹框 -->
|
<!-- 新增或编辑弹框 -->
|
||||||
<NModal
|
<NModal
|
||||||
v-model:show="roleModal"
|
v-model:show="roleModal"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="编辑角色"
|
title="编辑角色"
|
||||||
:auto-focus="false"
|
:auto-focus="false"
|
||||||
:style="{ width: '450px', height: 'auto' }"
|
:style="{ width: '450px', height: 'auto' }"
|
||||||
:segmented="{ content: true, footer: true }"
|
:segmented="{ content: true, footer: true }"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<NForm ref="formRef" :model="currentRole" :rules="rules" label-placement="left" label-width="auto">
|
<NForm ref="formRef" :model="currentRole" :rules="rules" label-placement="left" label-width="auto">
|
||||||
<NFormItem label="角色名称" path="name">
|
<NFormItem label="角色名称" path="name">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="currentRole.name"
|
v-model:value="currentRole.name"
|
||||||
placeholder="请输入角色名称"
|
placeholder="请输入角色名称"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
</NForm>
|
</NForm>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-[10px]">
|
<div class="flex justify-end gap-[10px]">
|
||||||
<NButton @click="roleModal = false">取消</NButton>
|
<NButton @click="roleModal = false">取消</NButton>
|
||||||
<NButton type="primary" :loading="btnLoading" @click="saveRole">确定</NButton>
|
<NButton type="primary" :loading="btnLoading" @click="saveRole">确定</NButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NModal>
|
</NModal>
|
||||||
<!-- 设置权限抽屉 -->
|
<!-- 设置权限抽屉 -->
|
||||||
<NDrawer v-model:show="permissionModal" :width="502" placement="right">
|
<NDrawer v-model:show="permissionModal" :width="502" placement="right">
|
||||||
<NDrawerContent :title="`设置权限-${currentRole.name}`" closable>
|
<NDrawerContent :title="`设置权限-${currentRole.name}`" closable>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-[10px]">
|
<div class="flex justify-end gap-[10px]">
|
||||||
<NButton @click="permissionModal = false">取消</NButton>
|
<NButton @click="permissionModal = false">取消</NButton>
|
||||||
<NButton type="primary" :loading="btnLoading" @click="savePermission">保存</NButton>
|
<NButton type="primary" :loading="btnLoading" @click="savePermission">保存</NButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NDrawerContent>
|
</NDrawerContent>
|
||||||
</NDrawer>
|
</NDrawer>
|
||||||
</NCard>
|
</NCard>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.scroll {
|
.scroll {
|
||||||
height: calc(100vh - 205px);
|
height: calc(100vh - 205px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -35,7 +35,15 @@ export default defineConfig(configEnv => {
|
|||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 9527,
|
port: 9527,
|
||||||
open: true,
|
open: true,
|
||||||
proxy: createViteProxy(viteEnv, enableProxy)
|
proxy: {
|
||||||
|
...createViteProxy(viteEnv, enableProxy),
|
||||||
|
// ai-server OpenApi(本地 docker ai-server-ai-1,:12333)
|
||||||
|
'/proxy-ai': {
|
||||||
|
target: process.env.AI_SERVER_URL || 'http://127.0.0.1:12333',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: p => p.replace(/^\/proxy-ai/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
port: 9725
|
port: 9725
|
||||||
|
|||||||
Reference in New Issue
Block a user