From f5176ada20b207ea2ada5cfb163ae379a3206787 Mon Sep 17 00:00:00 2001 From: "David.Chen" Date: Wed, 26 Aug 2026 22:10:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=8C=E6=88=90ai=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/api/ai.ts | 453 +++ src/service/api/role.ts | 38 +- src/store/modules/auth/index.ts | 8 + src/views/docs/archives/index.vue | 26 +- src/views/my/highRiskDetail/index.vue | 135 +- src/views/my/initiateChange/index.vue | 3500 +++++++++-------- src/views/my/initiateChange/modules/hazop.vue | 588 +-- src/views/my/initiateChange/modules/jsa.vue | 584 +-- .../my/initiateChange/modules/riskCheck.vue | 220 +- src/views/my/initiateChange/modules/scl.vue | 510 +-- src/views/systemManage/role/index.vue | 424 +- src/views/systemManage/user/index.vue | 1411 +++---- vite.config.ts | 10 +- 13 files changed, 4367 insertions(+), 3540 deletions(-) create mode 100644 src/service/api/ai.ts diff --git a/src/service/api/ai.ts b/src/service/api/ai.ts new file mode 100644 index 0000000..6079e7e --- /dev/null +++ b/src/service/api/ai.ts @@ -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 { + 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) { + 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(path: string, init: RequestInit): Promise> { + 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; + } catch { + throw new AiError(resp.status, `AI 服务响应异常(HTTP ${resp.status})`); + } +} + +async function getAppToken(): Promise { + 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 { + 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(path: string, payload?: unknown, method: 'GET' | 'POST' = 'POST', retried = false): Promise { + const token = await getAiToken(); + const body = await rawFetch(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(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('/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 | null; + risk_tools: string[]; + department_opinions?: any; +} + +export const aiApplication = (content: string, rows: CompareRow[]) => + aiFetch('/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('/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('/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('/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(`/open/v1/tasks/${taskId}`, undefined, 'GET'); + +export const getTaskResult = (taskId: string) => aiFetch(`/open/v1/tasks/${taskId}/result`, undefined, 'GET'); + +/** 轮询任务直至完成/失败 */ +export async function pollTask( + taskId: string, + opts: { intervalMs?: number; timeoutMs?: number; onProgress?: (t: OpenTask) => void } = {} +): Promise { + 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'); diff --git a/src/service/api/role.ts b/src/service/api/role.ts index d5cd270..e0ef813 100644 --- a/src/service/api/role.ts +++ b/src/service/api/role.ts @@ -1,19 +1,19 @@ -import { request } from '../request'; - -/** 获取角色列表 - * - */ -export function roleListApi() { - return request({ url: '/api/role' }); -} -/** - * 编辑角色 - * - */ -export function editRoleApi(params: { id: number, intro: string, name: string, perms: string[] }) { - return request({ - url: '/api/role', - method: 'post', - data: params - }); -} +import { request } from '../request'; + +/** 获取角色列表 + * + */ +export function roleListApi() { + return request({ url: '/api/role' }); +} +/** + * 编辑角色 + * + */ +export function editRoleApi(params: { id: number, intro: string, name: string, perms: string[] }) { + return request({ + url: '/api/role', + method: 'post', + data: params + }); +} diff --git a/src/store/modules/auth/index.ts b/src/store/modules/auth/index.ts index c68c3c4..a0b3468 100644 --- a/src/store/modules/auth/index.ts +++ b/src/store/modules/auth/index.ts @@ -3,6 +3,7 @@ import { useRoute } from 'vue-router'; import { defineStore } from 'pinia'; import { useLoading } from '@sa/hooks'; import { fetchGetUserInfo, fetchLogin } from '@/service/api'; +import { setAiUser } from '@/service/api/ai'; import { useRouterPush } from '@/hooks/common/router'; import { localStg } from '@/utils/storage'; import { SetupStoreId } from '@/enum'; @@ -151,6 +152,13 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => { // update store 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; } diff --git a/src/views/docs/archives/index.vue b/src/views/docs/archives/index.vue index 20b2cc0..e4149e8 100644 --- a/src/views/docs/archives/index.vue +++ b/src/views/docs/archives/index.vue @@ -3,6 +3,7 @@ import { ref, computed } from 'vue'; import { useAppStore } from '@/store/modules/app'; import { Icon } from '@iconify/vue' import { useThemeStore } from '@/store/modules/theme'; +import { aiKnowledgeIngest, pollTask, AiError } from '@/service/api/ai'; const appStore = useAppStore(); const themeStore = useThemeStore(); @@ -29,8 +30,26 @@ const preview = (d:any) => { const download = (d:any) => { window.$message?.success(`演示:下载 ${d.name}`) } -const call = (d:any) => { - window.$message?.success(`演示:已将「${d.name}」调用为当前变更的 AI 知识来源(调用记录留痕)`) +// 已纳入 AI 知识库的条目(name -> true)与摄入中状态 +const ingested = ref>({}); +const ingesting = ref>({}); +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 LibFolder { label: string; docs: LibDoc[] } @@ -210,7 +229,8 @@ const libCur = computed(() => LIB_ORGS.find((o) => o.org === libSel.value.org)?.
查看 下载 - 调用 + 已纳入 AI 知识库 + 调用
diff --git a/src/views/my/highRiskDetail/index.vue b/src/views/my/highRiskDetail/index.vue index 8f07ac1..2b981b2 100644 --- a/src/views/my/highRiskDetail/index.vue +++ b/src/views/my/highRiskDetail/index.vue @@ -1,5 +1,6 @@ - - - - + + + + + diff --git a/src/views/my/initiateChange/modules/hazop.vue b/src/views/my/initiateChange/modules/hazop.vue index fe5230e..3230db8 100644 --- a/src/views/my/initiateChange/modules/hazop.vue +++ b/src/views/my/initiateChange/modules/hazop.vue @@ -1,283 +1,305 @@ - - - + + + diff --git a/src/views/my/initiateChange/modules/jsa.vue b/src/views/my/initiateChange/modules/jsa.vue index b5184d7..51428f6 100644 --- a/src/views/my/initiateChange/modules/jsa.vue +++ b/src/views/my/initiateChange/modules/jsa.vue @@ -1,281 +1,303 @@ - - - + + + diff --git a/src/views/my/initiateChange/modules/riskCheck.vue b/src/views/my/initiateChange/modules/riskCheck.vue index f9c7acc..59d6686 100644 --- a/src/views/my/initiateChange/modules/riskCheck.vue +++ b/src/views/my/initiateChange/modules/riskCheck.vue @@ -1,109 +1,111 @@ - - - + + + diff --git a/src/views/my/initiateChange/modules/scl.vue b/src/views/my/initiateChange/modules/scl.vue index c6d4d3b..6f924b0 100644 --- a/src/views/my/initiateChange/modules/scl.vue +++ b/src/views/my/initiateChange/modules/scl.vue @@ -1,237 +1,273 @@ - - - + + + diff --git a/src/views/systemManage/role/index.vue b/src/views/systemManage/role/index.vue index d779d2b..2438ef5 100644 --- a/src/views/systemManage/role/index.vue +++ b/src/views/systemManage/role/index.vue @@ -1,212 +1,212 @@ - - - - - + + + + + diff --git a/src/views/systemManage/user/index.vue b/src/views/systemManage/user/index.vue index e30e04b..77245d0 100644 --- a/src/views/systemManage/user/index.vue +++ b/src/views/systemManage/user/index.vue @@ -1,705 +1,706 @@ - - - - - + + + + + diff --git a/vite.config.ts b/vite.config.ts index 45c2f97..1efe891 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,7 +35,15 @@ export default defineConfig(configEnv => { host: '0.0.0.0', port: 9527, 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: { port: 9725