变更预识别板块修改
This commit is contained in:
+11
-5
@@ -6,7 +6,7 @@
|
|||||||
* - 本地联调形态:前端直连 exchange(moc-server 的登录双令牌下发链路就绪后,
|
* - 本地联调形态:前端直连 exchange(moc-server 的登录双令牌下发链路就绪后,
|
||||||
* 改为登录时由 moc-server 下发 ai_token,本层仅需替换 getAiToken 来源)
|
* 改为登录时由 moc-server 下发 ai_token,本层仅需替换 getAiToken 来源)
|
||||||
*/
|
*/
|
||||||
|
import { request } from '../request/ai';
|
||||||
/** ai-server 统一响应 */
|
/** ai-server 统一响应 */
|
||||||
interface AiResp<T = any> {
|
interface AiResp<T = any> {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -129,9 +129,8 @@ function nextRequestId(prefix: string): string {
|
|||||||
|
|
||||||
export interface CompareRow {
|
export interface CompareRow {
|
||||||
item: string;
|
item: string;
|
||||||
before: string;
|
before_text: string;
|
||||||
after: string;
|
after_text: string;
|
||||||
hl?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatrixPayload {
|
export interface MatrixPayload {
|
||||||
@@ -219,13 +218,20 @@ export interface PreRiskResult {
|
|||||||
probability: string;
|
probability: string;
|
||||||
protection: string;
|
protection: string;
|
||||||
}
|
}
|
||||||
|
// 风险预分析
|
||||||
export const aiRiskPreAnalysis = (content: string, rows: CompareRow[]) =>
|
export const aiRiskPreAnalysis = (content: string, rows: CompareRow[]) =>
|
||||||
aiFetch<PreRiskResult>('/open/v1/moc/change/risk-pre-analysis', {
|
aiFetch<PreRiskResult>('/open/v1/moc/change/risk-pre-analysis', {
|
||||||
request_id: nextRequestId('pre'),
|
request_id: nextRequestId('pre'),
|
||||||
content,
|
content,
|
||||||
rows
|
rows
|
||||||
});
|
});
|
||||||
|
// export function aiRiskPreAnalysis (params: { content: string, rows: CompareRow[] }) {
|
||||||
|
// return request({
|
||||||
|
// url: '/open/v1/moc/change/risk-pre-analysis',
|
||||||
|
// method: 'post',
|
||||||
|
// data: params
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
export interface TypeJudgeResult {
|
export interface TypeJudgeResult {
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import {
|
|||||||
aiTypeJudge,
|
aiTypeJudge,
|
||||||
type CompareRow,
|
type CompareRow,
|
||||||
} from "@/service/api/ai";
|
} from "@/service/api/ai";
|
||||||
import HighlightText from "./modules/HighlightText.vue";
|
|
||||||
import Ed from "./modules/Ed.vue";
|
import Ed from "./modules/Ed.vue";
|
||||||
import Hazop from "./modules/hazop.vue";
|
import Hazop from "./modules/hazop.vue";
|
||||||
import Jsa from "./modules/jsa.vue";
|
import Jsa from "./modules/jsa.vue";
|
||||||
import Scl from "./modules/scl.vue";
|
import Scl from "./modules/scl.vue";
|
||||||
import RiskCheck from "./modules/riskCheck.vue";
|
import RiskCheck from "./modules/riskCheck.vue";
|
||||||
|
import ChangeCompareTable from "./modules/ChangeCompareTable.vue";
|
||||||
|
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
@@ -47,33 +47,11 @@ const form = ref<ApplyForm>({
|
|||||||
const confirmed = ref<Record<string, boolean>>({});
|
const confirmed = ref<Record<string, boolean>>({});
|
||||||
const blockedTabs = ref<string[]>([]);
|
const blockedTabs = ref<string[]>([]);
|
||||||
|
|
||||||
const aiEnabled = ref<boolean>(true);
|
|
||||||
const analyzing = ref<boolean>(false);
|
|
||||||
// ---------- 变更预识别 ----------
|
// ---------- 变更预识别 ----------
|
||||||
const desc = ref<string>(
|
const desc = ref<string>(
|
||||||
"某缩合反应现在控在80℃要反应8小时,车间反馈产能跟不上。我们小试做了几组,90℃下4小时转化率就能到98%,但有个未知杂质会从0.05%涨到0.12%。这个杂质结构还没定,不过不是基因毒性警示结构。想申请把温度提到90℃,同时把反应时间缩到4小时。",
|
"某缩合反应现在控在80℃要反应8小时,车间反馈产能跟不上。我们小试做了几组,90℃下4小时转化率就能到98%,但有个未知杂质会从0.05%涨到0.12%。这个杂质结构还没定,不过不是基因毒性警示结构。想申请把温度提到90℃,同时把反应时间缩到4小时。",
|
||||||
);
|
);
|
||||||
const rows = ref<any>(null);
|
const rows = ref<any>(null);
|
||||||
const AI_ROWS = [
|
|
||||||
{
|
|
||||||
id: 1, item: "工艺流程",
|
|
||||||
before: "流程简述(含物料、设备):某缩合反应,原料A与原料B在缩合反应釜中混合,加热至80℃进行保温反应8小时,反应完成后转入后处理工序。\n设计意图:通过缩合反应制得目标产物,保证转化率和杂质水平。",
|
|
||||||
after: "流程简述(含物料、设备):保温温度提升至90℃,反应时间缩短为4小时,其余流程不变。\n设计意图:在保持转化率98%的前提下缩短反应时间、提升产能。",
|
|
||||||
hl: ["保温温度提升至90℃,反应时间缩短为4小时", "在保持转化率98%的前提下缩短反应时间、提升产能"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2, item: "设备信息",
|
|
||||||
before: "基本信息(名称、设计参数、材质、用途):缩合反应釜,设计温度100℃,设计压力0.2MPa,材质SS304,用于缩合反应。\n使用信息(安装位置、连接方式、维护策略):安装于反应工段,夹套加热,按预防性维护计划定期检查。",
|
|
||||||
after: "无变化",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3, item: "运行条件",
|
|
||||||
before: "运行参数:反应温度80±2℃,保温时间8小时,压力常压。\n管控措施:DCS温度高报警设定85℃,高高报警90℃并联锁切断加热介质。",
|
|
||||||
after: "运行参数:反应温度改为90±2℃,保温时间改为4小时,压力不变。\n管控措施:DCS温度高报警需上调至95℃,高高报警及联锁值需上调至100℃。",
|
|
||||||
hl: ["90±2℃", "4小时", "DCS温度高报警需上调至95℃,高高报警及联锁值需上调至100℃"],
|
|
||||||
},
|
|
||||||
{ id: 4, item: "操作方式", before: "DCS自动化控制,远程操作,单人监控。", after: "无变化" },
|
|
||||||
];
|
|
||||||
// AI 调用失败统一提示:不阻塞页面、不清空已有数据,保留手动编辑能力(P1)
|
// AI 调用失败统一提示:不阻塞页面、不清空已有数据,保留手动编辑能力(P1)
|
||||||
const aiFail = (e: any) => {
|
const aiFail = (e: any) => {
|
||||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||||
@@ -81,102 +59,20 @@ const aiFail = (e: any) => {
|
|||||||
};
|
};
|
||||||
// 页面当前对比行 → AI 契约行
|
// 页面当前对比行 → AI 契约行
|
||||||
const compareRows = (): CompareRow[] => (rows.value ?? []).map((x: any) => ({ item: x.item, before: x.before, after: x.after, hl: x.hl }));
|
const compareRows = (): CompareRow[] => (rows.value ?? []).map((x: any) => ({ item: x.item, before: x.before, after: x.after, hl: x.hl }));
|
||||||
// ---------- AI 变更识别 ----------
|
|
||||||
const runIdentify = async () => {
|
|
||||||
if (!desc.value.trim()) { window.$message?.warning("请先输入变更内容描述"); return; }
|
|
||||||
analyzing.value = true;
|
|
||||||
rows.value = null;
|
|
||||||
try {
|
|
||||||
const res = await aiRecognize(desc.value);
|
|
||||||
rows.value = (res?.rows ?? []).map((x, i) => ({ id: i + 1, item: x.item, before: x.before, after: x.after, hl: x.hl }));
|
|
||||||
window.$message?.success(`AI 变更识别完成,共 ${rows.value.length} 项,请逐项人工确认(可直接修改)`);
|
|
||||||
} catch (e: any) {
|
|
||||||
rows.value = AI_ROWS.map((x) => ({ ...x }));
|
|
||||||
aiFail(e);
|
|
||||||
} finally {
|
|
||||||
analyzing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const editing = ref<any>(null);
|
const editing = ref<any>(null);
|
||||||
|
|
||||||
// ---------- 风险预分析 ----------
|
// ---------- 风险预分析 ----------
|
||||||
const preRiskEdit = ref<any>(null);
|
|
||||||
const preRiskShown = ref<boolean>(false);
|
|
||||||
const preRiskText = ref({
|
|
||||||
severity: "本次变更为缩合反应温度由80℃提升至90℃、保温时间由8h缩短至4h。变更前后反应体系与物料不变,不新增化学品,不存在新增燃爆、毒害、腐蚀等危害严重性变化;温度提升后仍在溶剂沸点与物料分解温度的安全裕度之内。综上,本次变更未显著增加潜在事件的危害严重程度,事故后果(人员伤亡、财产损失、环境污染)的严重等级与变更前基本一致;但杂质水平由0.05%升至0.12%,需关注长期运行对产品质量与设备结垢的累积影响。",
|
|
||||||
probability: "变更引入了新的偏差场景。首先,反应温度更接近溶剂回流温度,温控失效或冷却水中断时,超温溜温概率上升,可能引发冲料;其次,保温时间缩短使反应终点判断窗口变窄,人为误判导致转化率不足或副反应增加的概率上升;再者,未知杂质含量升高(0.05%→0.12%),其热稳定性尚未定性,长期累积可能加速釜壁结垢与搅拌负荷异常;此外,DCS报警/联锁值上调后原有报警裕度改变,误报、漏报概率需重新评估。",
|
|
||||||
protection: "变更主要影响与温度控制相关的保护层。原DCS温度高报警85℃、高高报警及联锁90℃已不适用于新工艺窗口,需同步上调至95℃/100℃并重新整定验证;安全阀起跳压力与泄放能力不受本次温度调整直接影响,仍然有效;操作规程与应急预案须修订后,方可继续作为有效的人为保护层。建议投用前完成联锁测试与报警验证,并将杂质含量纳入日常分析计划。",
|
|
||||||
});
|
|
||||||
const runRiskAnalysis = async () => {
|
|
||||||
analyzing.value = true;
|
|
||||||
try {
|
|
||||||
const res = await aiRiskPreAnalysis(desc.value, compareRows());
|
|
||||||
preRiskText.value = {
|
|
||||||
severity: res?.severity ?? preRiskText.value.severity,
|
|
||||||
probability: res?.probability ?? preRiskText.value.probability,
|
|
||||||
protection: res?.protection ?? preRiskText.value.protection,
|
|
||||||
};
|
|
||||||
preRiskShown.value = true;
|
|
||||||
window.$message?.success("AI 风险预分析报告已生成(危害严重性 / 事件概率 / 保护措施影响),内容支持手动修改");
|
|
||||||
} catch (e: any) {
|
|
||||||
preRiskShown.value = true;
|
|
||||||
aiFail(e);
|
|
||||||
} finally {
|
|
||||||
analyzing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const saveDraft = () => window.$message?.success("已保存为草稿,可在左侧「我的草稿」中继续编辑");
|
const saveDraft = () => window.$message?.success("已保存为草稿,可在左侧「我的草稿」中继续编辑");
|
||||||
|
|
||||||
// ---------- 生成变更申请单 ----------
|
// ---------- 生成变更申请单 ----------
|
||||||
const formGen = ref<string>("idle");
|
|
||||||
const materialsText = ref("");
|
const materialsText = ref("");
|
||||||
const updateDocs = ref<string[]>([]);
|
const updateDocs = ref<string[]>([]);
|
||||||
const disciplines = ref<string[]>([]);
|
const disciplines = ref<string[]>([]);
|
||||||
const tools = ref<string[]>(["HAZOP","JSA", "SCL", "RISK_CHECK"]);
|
const tools = ref<string[]>(["HAZOP","JSA", "SCL", "RISK_CHECK"]);
|
||||||
const UPDATE_DOC_OPTIONS = ['PID图纸', '操作规程', '总图', '设备台账', '工艺卡片', '联锁台账', '应急预案'];
|
const UPDATE_DOC_OPTIONS = ['PID图纸', '操作规程', '总图', '设备台账', '工艺卡片', '联锁台账', '应急预案'];
|
||||||
const DISCIPLINE_OPTIONS = ['技术', '仪表', '电气', '环保', '质量'];
|
const DISCIPLINE_OPTIONS = ['技术', '仪表', '电气', '环保', '质量'];
|
||||||
const genForm = async () => {
|
|
||||||
if (formGen.value === "generating") return;
|
|
||||||
formGen.value = "generating";
|
|
||||||
window.$message?.info("AI 正在生成变更申请单…(「变更申请表」标签已显示生成中标识)");
|
|
||||||
try {
|
|
||||||
const res = await aiApplication(desc.value, compareRows());
|
|
||||||
const pick = (v: string | null | undefined, opts: string[], cur: string) => (v && opts.includes(v) ? v : cur);
|
|
||||||
const main = (res?.main_changes ?? []).filter((x) => x && x.trim());
|
|
||||||
const purposes = (res?.purposes ?? []).filter((x) => PURPOSE_OPTIONS.includes(x));
|
|
||||||
const docs = (res?.update_docs ?? []).filter((x) => UPDATE_DOC_OPTIONS.includes(x));
|
|
||||||
const discs = (res?.disciplines ?? []).filter((x) => DISCIPLINE_OPTIONS.includes(x));
|
|
||||||
const aiTools = (res?.risk_tools ?? [])
|
|
||||||
.map((x) => RISK_TOOLS_FORM.find((t) => t.key === x || t.label === x)?.key)
|
|
||||||
.filter((x): x is string => !!x);
|
|
||||||
form.value = {
|
|
||||||
...form.value,
|
|
||||||
name: res?.name || form.value.name,
|
|
||||||
mainChanges: main.length ? main : form.value.mainChanges,
|
|
||||||
related: (res?.related_changes ?? []).filter(Boolean).join("、") || form.value.related,
|
|
||||||
purposes: purposes.length ? purposes : form.value.purposes,
|
|
||||||
effect: res?.effect || form.value.effect,
|
|
||||||
type: pick(res?.change_type, ['工艺', '设备', '管理'], form.value.type),
|
|
||||||
duration: pick(res?.duration_suggestion, ['永久', '临时'], form.value.duration),
|
|
||||||
};
|
|
||||||
if (res?.materials_text) materialsText.value = res.materials_text;
|
|
||||||
if (docs.length) updateDocs.value = docs;
|
|
||||||
if (discs.length) {
|
|
||||||
disciplines.value = discs;
|
|
||||||
toggleDiscipline(discs);
|
|
||||||
}
|
|
||||||
if (res?.signers && Object.keys(res.signers).length) signers.value = { ...res.signers };
|
|
||||||
if (aiTools.length) tools.value = aiTools;
|
|
||||||
currentPlugin.value = "";
|
|
||||||
formGen.value = "done";
|
|
||||||
tab.value = "form";
|
|
||||||
window.$message?.success("变更申请单已生成:AI 自动总结名称、预填内容,均可手动修改");
|
|
||||||
} catch (e: any) {
|
|
||||||
formGen.value = "idle";
|
|
||||||
aiFail(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// ---------- 审批流程设置 ----------
|
// ---------- 审批流程设置 ----------
|
||||||
const approvers = ref<Record<string, string>>({
|
const approvers = ref<Record<string, string>>({
|
||||||
部门审核: "李主任(一车间主任)",
|
部门审核: "李主任(一车间主任)",
|
||||||
@@ -686,7 +582,25 @@ const onBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---------- tab 切换 ----------
|
// **********************通用字段***********************
|
||||||
|
// 是否开启ai
|
||||||
|
const aiEnabled = ref<boolean>(true);
|
||||||
|
// 保存loading
|
||||||
|
const saveLoading = ref<boolean>(false);
|
||||||
|
// 变更识别loading
|
||||||
|
const preLoading = ref<boolean>(false);
|
||||||
|
// 风险预分析loading
|
||||||
|
const analyzing = ref<boolean>(false);
|
||||||
|
// 生成变更申请单loading
|
||||||
|
const generateLoading = ref<boolean>(false);
|
||||||
|
// btn disabled
|
||||||
|
const btnDisabled = computed(() => saveLoading.value || preLoading.value || analyzing.value || generateLoading.value);
|
||||||
|
// 变更名称
|
||||||
|
const title = ref<string>('');
|
||||||
|
// 当前id
|
||||||
|
const currentId = ref<number>(0);
|
||||||
|
|
||||||
|
// **********************tab切换***********************
|
||||||
const tab = ref<string>("pre");
|
const tab = ref<string>("pre");
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: "pre", name: "变更预识别", icon: 'lucide:sparkles' },
|
{ id: "pre", name: "变更预识别", icon: 'lucide:sparkles' },
|
||||||
@@ -697,19 +611,153 @@ const TABS = [
|
|||||||
{ id: "accept", name: "验收评价", icon: 'material-symbols:check-circle-outline' },
|
{ id: "accept", name: "验收评价", icon: 'material-symbols:check-circle-outline' },
|
||||||
{ id: "close", name: "变更关闭确认表", icon: 'quill:folder-open' },
|
{ id: "close", name: "变更关闭确认表", icon: 'quill:folder-open' },
|
||||||
];
|
];
|
||||||
// 当前id
|
|
||||||
const currentId = ref<number>(0);
|
|
||||||
// tab 切换
|
// tab 切换
|
||||||
const tabChange = (id: string) => {
|
const tabChange = (id: string) => {
|
||||||
if(form.value.name.trim() === '') {
|
if(tab.value === id) {
|
||||||
return window.$message?.warning('请先填写变更名称');
|
return;
|
||||||
}
|
|
||||||
if(desc.value.trim() === '') {
|
|
||||||
return window.$message?.warning('请先填写变更内容描述');
|
|
||||||
}
|
}
|
||||||
|
// if(title.value.trim() === '') {
|
||||||
|
// return window.$message?.warning('请先填写变更名称');
|
||||||
|
// }
|
||||||
|
// if(formOne.value.description.trim() === '') {
|
||||||
|
// return window.$message?.warning('请先填写变更内容描述');
|
||||||
|
// }
|
||||||
tab.value = id;
|
tab.value = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **********************变更预识别***********************
|
||||||
|
// 是否是ai生成
|
||||||
|
const isAiGenerated = ref<boolean>(false);
|
||||||
|
// 表单
|
||||||
|
const formOne = ref<{
|
||||||
|
description: string,
|
||||||
|
compare_rows: {item: string, before_text: string, after_text: string}[],
|
||||||
|
severity: string,
|
||||||
|
probability: string,
|
||||||
|
protection: string
|
||||||
|
}>({
|
||||||
|
description: '',
|
||||||
|
compare_rows: [],
|
||||||
|
severity: "",
|
||||||
|
probability: "",
|
||||||
|
protection: "",
|
||||||
|
});
|
||||||
|
// AI 变更识别
|
||||||
|
const runIdentify = async () => {
|
||||||
|
if (!formOne.value.description.trim()) {
|
||||||
|
return window.$message?.warning("请先输入变更内容描述");
|
||||||
|
}
|
||||||
|
preLoading.value = true;
|
||||||
|
formOne.value.compare_rows = [];
|
||||||
|
try {
|
||||||
|
const res = await aiRecognize(formOne.value.description);
|
||||||
|
formOne.value.compare_rows = (res?.rows ?? []).map((x, i) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text }));
|
||||||
|
window.$message?.success(`AI 变更识别完成,共 ${formOne.value.compare_rows.length} 项,请逐项人工确认(可直接修改)`);
|
||||||
|
} catch (e: any) {
|
||||||
|
formOne.value.compare_rows = [];
|
||||||
|
aiFail(e);
|
||||||
|
} finally {
|
||||||
|
preLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑风险预分析
|
||||||
|
const preRiskEdit = ref<string>('');
|
||||||
|
// 是否显示险预分析
|
||||||
|
const preRiskShow = ref<boolean>(false);
|
||||||
|
// 运行风险与分析
|
||||||
|
const runRiskAnalysis = async () => {
|
||||||
|
if(formOne.value.description.trim() === '') {
|
||||||
|
return window.$message?.warning('请先填写变更内容描述');
|
||||||
|
}
|
||||||
|
analyzing.value = true;
|
||||||
|
try {
|
||||||
|
const res = await aiRiskPreAnalysis(formOne.value.description, formOne.value.compare_rows);
|
||||||
|
formOne.value.severity = res?.severity ?? formOne.value.severity;
|
||||||
|
formOne.value.probability = res?.probability ?? formOne.value.probability
|
||||||
|
formOne.value.protection = res?.protection ?? formOne.value.protection
|
||||||
|
preRiskShow.value = true;
|
||||||
|
window.$message?.success("AI 风险预分析报告已生成(危害严重性 / 事件概率 / 保护措施影响),内容支持手动修改");
|
||||||
|
} catch (e: any) {
|
||||||
|
aiFail(e);
|
||||||
|
} finally {
|
||||||
|
analyzing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// const {data,error} = await aiRiskPreAnalysis({
|
||||||
|
// content: formOne.value.description,
|
||||||
|
// rows: formOne.value.compare_rows
|
||||||
|
// });
|
||||||
|
// if(!error){
|
||||||
|
// formOne.value.severity = data?.severity ?? formOne.value.severity;
|
||||||
|
// formOne.value.probability = data?.probability ?? formOne.value.probability
|
||||||
|
// formOne.value.protection = data?.protection ?? formOne.value.protection
|
||||||
|
// preRiskShow.value = true;
|
||||||
|
// window.$message?.success("AI 风险预分析报告已生成(危害严重性 / 事件概率 / 保护措施影响),内容支持手动修改");
|
||||||
|
// }
|
||||||
|
// analyzing.value = false;
|
||||||
|
};
|
||||||
|
// 生成变更申请单
|
||||||
|
const genForm = async () => {
|
||||||
|
window.$message?.info("AI 正在生成变更申请单");
|
||||||
|
generateLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await aiApplication(desc.value, compareRows());
|
||||||
|
const pick = (v: string | null | undefined, opts: string[], cur: string) => (v && opts.includes(v) ? v : cur);
|
||||||
|
const main = (res?.main_changes ?? []).filter((x) => x && x.trim());
|
||||||
|
const purposes = (res?.purposes ?? []).filter((x) => PURPOSE_OPTIONS.includes(x));
|
||||||
|
const docs = (res?.update_docs ?? []).filter((x) => UPDATE_DOC_OPTIONS.includes(x));
|
||||||
|
const discs = (res?.disciplines ?? []).filter((x) => DISCIPLINE_OPTIONS.includes(x));
|
||||||
|
const aiTools = (res?.risk_tools ?? [])
|
||||||
|
.map((x) => RISK_TOOLS_FORM.find((t) => t.key === x || t.label === x)?.key)
|
||||||
|
.filter((x): x is string => !!x);
|
||||||
|
form.value = {
|
||||||
|
...form.value,
|
||||||
|
name: res?.name || form.value.name,
|
||||||
|
mainChanges: main.length ? main : form.value.mainChanges,
|
||||||
|
related: (res?.related_changes ?? []).filter(Boolean).join("、") || form.value.related,
|
||||||
|
purposes: purposes.length ? purposes : form.value.purposes,
|
||||||
|
effect: res?.effect || form.value.effect,
|
||||||
|
type: pick(res?.change_type, ['工艺', '设备', '管理'], form.value.type),
|
||||||
|
duration: pick(res?.duration_suggestion, ['永久', '临时'], form.value.duration),
|
||||||
|
};
|
||||||
|
if (res?.materials_text) materialsText.value = res.materials_text;
|
||||||
|
if (docs.length) updateDocs.value = docs;
|
||||||
|
if (discs.length) {
|
||||||
|
disciplines.value = discs;
|
||||||
|
toggleDiscipline(discs);
|
||||||
|
}
|
||||||
|
if (res?.signers && Object.keys(res.signers).length) signers.value = { ...res.signers };
|
||||||
|
if (aiTools.length) tools.value = aiTools;
|
||||||
|
currentPlugin.value = "";
|
||||||
|
tab.value = "form";
|
||||||
|
window.$message?.success("变更申请单已生成:AI 自动总结名称、预填内容,均可手动修改");
|
||||||
|
} catch (e: any) {
|
||||||
|
aiFail(e);
|
||||||
|
} finally {
|
||||||
|
generateLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------- 变更申请表 ---------------------
|
||||||
|
// 表单
|
||||||
|
const changForm = ref<{
|
||||||
|
name: string,
|
||||||
|
mainChanges: string[],
|
||||||
|
related: string,
|
||||||
|
purposes: string[],
|
||||||
|
effect: string,
|
||||||
|
type: string,
|
||||||
|
duration: string
|
||||||
|
}>({
|
||||||
|
name: '',
|
||||||
|
mainChanges: [],
|
||||||
|
related: '',
|
||||||
|
purposes: [],
|
||||||
|
effect: '',
|
||||||
|
type: '',
|
||||||
|
duration: ''
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -730,7 +778,7 @@ const tabChange = (id: string) => {
|
|||||||
:style="{'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)}"
|
:style="{'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)}"
|
||||||
>
|
>
|
||||||
<span class="whitespace-nowrap text-sm font-medium text-slate-600">变更名称 <span class="text-red-500">*</span></span>
|
<span class="whitespace-nowrap text-sm font-medium text-slate-600">变更名称 <span class="text-red-500">*</span></span>
|
||||||
<NInput v-model:value="form.name" maxlength="28" show-count placeholder="AI 自动总结(28 字以内),可手动修改" class="!w-420px" />
|
<NInput v-model:value="title" maxlength="28" show-count placeholder="AI 自动总结(28 字以内),可手动修改" class="!w-420px" />
|
||||||
<span class="ml-auto whitespace-nowrap text-sm text-slate-500">变更编号</span>
|
<span class="ml-auto whitespace-nowrap text-sm text-slate-500">变更编号</span>
|
||||||
<NInput value="MOC-2026-R01-0041" disabled class="!w-44" />
|
<NInput value="MOC-2026-R01-0041" disabled class="!w-44" />
|
||||||
</div>
|
</div>
|
||||||
@@ -745,13 +793,11 @@ const tabChange = (id: string) => {
|
|||||||
>
|
>
|
||||||
<Icon :icon="t.icon" class="size-14px" />
|
<Icon :icon="t.icon" class="size-14px" />
|
||||||
{{ t.name }}
|
{{ t.name }}
|
||||||
<span v-if="t.id === 'form' && formGen === 'generating'"
|
<span v-if="(btnDisabled && t.id === 'pre')">
|
||||||
class="flex items-center gap-1 rounded-full border border-violet-300 bg-violet-50 px-1.5 py-0.5 text-[10px] text-violet-600">
|
|
||||||
<Icon icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
<Icon icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
||||||
AI 生成中
|
|
||||||
</span>
|
</span>
|
||||||
<Icon v-if="confirmed[t.id]" icon="material-symbols:check-circle-outline" class="size-14px text-emerald-500" />
|
<Icon v-if="confirmed[t.id]" icon="material-symbols:check-circle-outline" class="size-14px text-emerald-500" />
|
||||||
<span v-if="!confirmed[t.id] && blockedTabs.includes(t.id)" class="h-1.5 w-1.5 rounded-full bg-red-500" title="本页尚未确认完毕" />
|
<span v-if="!confirmed[t.id] && blockedTabs.includes(t.id)" class="h-1.5 w-1.5 rounded-full bg-red-500" title="本页尚未确认完毕"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -767,15 +813,15 @@ const tabChange = (id: string) => {
|
|||||||
<Icon icon="lucide:sparkles" class="size-16px text-violet-500" />
|
<Icon icon="lucide:sparkles" class="size-16px text-violet-500" />
|
||||||
<span class="font-semibold text-base ml-2">变更内容描述</span>
|
<span class="font-semibold text-base ml-2">变更内容描述</span>
|
||||||
</div>
|
</div>
|
||||||
<NButton v-if="aiEnabled" ghost type="primary" @click="runIdentify" :disabled="analyzing">
|
<NButton v-if="aiEnabled" ghost type="primary" @click="runIdentify" :disabled="btnDisabled">
|
||||||
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-16px animate-spin" />
|
<Icon v-if="preLoading" icon="ri:loader-4-fill" class="size-16px animate-spin" />
|
||||||
<Icon v-else icon="fluent:wand-24-regular" class="size-16px" />
|
<Icon v-else icon="fluent:wand-24-regular" class="size-16px" />
|
||||||
变更识别
|
变更识别
|
||||||
</NButton>
|
</NButton>
|
||||||
<NTag type="info" v-else>AI 未启用</NTag>
|
<NTag type="info" v-else>AI 未启用</NTag>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<NInput type="textarea" v-model:value="desc" rows="4" placeholder="请输入变更内容描述,例如:将反应釜R-101的搅拌速度从120rpm提高至180rpm,同时将反应温度从80℃提高至95℃…" />
|
<NInput type="textarea" v-model:value="formOne.description" rows="4" placeholder="请输入变更内容描述,例如:将反应釜R-101的搅拌速度从120rpm提高至180rpm,同时将反应温度从80℃提高至95℃…" />
|
||||||
<div v-if="!aiEnabled" class="mt-2 flex items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
<div v-if="!aiEnabled" class="mt-2 flex items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
<Icon icon="proicons:alert-triangle" class="size-14px" />
|
<Icon icon="proicons:alert-triangle" class="size-14px" />
|
||||||
AI 辅助功能未启用(可在「系统设置 → AI 辅助功能」开启),识别结果与申请表需手动填写。
|
AI 辅助功能未启用(可在「系统设置 → AI 辅助功能」开启),识别结果与申请表需手动填写。
|
||||||
@@ -787,77 +833,22 @@ const tabChange = (id: string) => {
|
|||||||
<div class="flex items-center justify-between px-4 py-2 border-b">
|
<div class="flex items-center justify-between px-4 py-2 border-b">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
|
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
|
||||||
<span class="font-semibold text-base ml-2">{{rows ? `变更识别结果(${rows.length}项)` : '变更识别结果(空表 · 可手动录入,或由 AI 识别生成)'}}</span>
|
<span class="font-semibold text-base ml-2">{{formOne.compare_rows.length ? `变更识别结果(${formOne.compare_rows.length}项)` : '变更识别结果(空表 · 可手动录入,或由 AI 识别生成)'}}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="rows" class="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">
|
<span v-if="isAiGenerated" class="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">
|
||||||
✨ AI 生成 · 需人工确认
|
✨ AI 生成 · 需人工确认
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="overflow-x-auto">
|
<ChangeCompareTable
|
||||||
<table class="w-full min-w-[640px] border-collapse text-sm">
|
:data="formOne.compare_rows"
|
||||||
<thead>
|
:editable="true"
|
||||||
<tr class="bg-slate-50 text-left text-slate-500">
|
@update:data="(d) => formOne.compare_rows = d"
|
||||||
<th class="w-28 border-b px-3 py-2 font-medium">变更项目</th>
|
|
||||||
<th class="border-b px-3 py-2 font-medium">变更前</th>
|
|
||||||
<th class="border-b px-3 py-2 font-medium">变更后</th>
|
|
||||||
<th class="w-10 border-b px-2 py-2" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in rows ?? []" :key="row.id" class="align-top">
|
|
||||||
<td class="border-b px-3 py-3">
|
|
||||||
<NInput
|
|
||||||
v-if="editing === `${row.id}-item`"
|
|
||||||
class="text-left"
|
|
||||||
type="textarea"
|
|
||||||
size="small"
|
|
||||||
autofocus
|
|
||||||
v-model:value="row.item"
|
|
||||||
:autosize="{ minRows: 1 }"
|
|
||||||
placeholder="请输入"
|
|
||||||
@blur="editing = null"
|
|
||||||
/>
|
/>
|
||||||
<p v-else class="font-medium text-slate-700 hover:text-[#17407F] cursor-pointer" @click="editing = `${row.id}-item`" title="点击修改项目名称">
|
|
||||||
{{ row.item }}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
<td v-for="field in (['before', 'after'])" :key="field" class="border-b px-3 py-3">
|
|
||||||
<NInput
|
|
||||||
v-if="editing === `${row.id}-${field}`"
|
|
||||||
class="text-left"
|
|
||||||
type="textarea"
|
|
||||||
size="small"
|
|
||||||
autofocus
|
|
||||||
v-model:value="row[field]"
|
|
||||||
:autosize="{ minRows: 1 }"
|
|
||||||
placeholder="请输入"
|
|
||||||
@blur="editing = null"
|
|
||||||
/>
|
|
||||||
<p v-else class="min-h-30px cursor-pointer rounded-lg border bg-slate-50/60 px-2.3 py-0.55 text-left text-sm leading-relaxed text-slate-700 transition hover:border-[#6E93C6] hover:bg-white" @click="editing = `${row.id}-${field}`" title="点击编辑">
|
|
||||||
<HighlightText v-if="field === 'after'" :text="row[field]" :hl="row.hl" />
|
|
||||||
<span v-else class="whitespace-pre-line">{{ row[field] }}</span>
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
<td class="border-b px-2 py-3 text-center">
|
|
||||||
<NButton text @click="rows = rows && rows.filter((x) => x.id !== row.id)" class="text-slate-300 hover:text-red-500" title="删除此行">
|
|
||||||
<Icon icon="line-md:close-circle" class="size-16px" />
|
|
||||||
</NButton>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
<NButton ghost
|
|
||||||
@click="rows = [...(rows ?? []), { id: Date.now(), item: '新项目', before: '', after: '' }]">
|
|
||||||
<Icon icon="ic:round-plus" class="size-16px" /> 添加行
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 风险预分析报告 -->
|
<!-- 风险预分析报告 -->
|
||||||
<div v-if="preRiskShown" class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
<div v-if="preRiskShow" class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b">
|
<div class="flex items-center justify-between px-4 py-2 border-b">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
|
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
|
||||||
@@ -872,12 +863,12 @@ const tabChange = (id: string) => {
|
|||||||
<div v-for="s in ([{ key: 'severity', name: '危害严重性分析' }, { key: 'probability', name: '事件概率分析' }, { key: 'protection', name: '保护措施影响分析' }] as const)" :key="s.key" class="rounded-lg border">
|
<div v-for="s in ([{ key: 'severity', name: '危害严重性分析' }, { key: 'probability', name: '事件概率分析' }, { key: 'protection', name: '保护措施影响分析' }] as const)" :key="s.key" class="rounded-lg border">
|
||||||
<div class="flex items-center gap-2 border-b bg-slate-50/60 px-4 py-2">
|
<div class="flex items-center gap-2 border-b bg-slate-50/60 px-4 py-2">
|
||||||
<span class="text-sm font-semibold text-slate-700">{{ s.name }}</span>
|
<span class="text-sm font-semibold text-slate-700">{{ s.name }}</span>
|
||||||
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="preRiskEdit = preRiskEdit === s.key ? null : s.key">
|
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="preRiskEdit = preRiskEdit === s.key ? '' : s.key">
|
||||||
{{ preRiskEdit === s.key ? "完成" : "编辑" }}
|
{{ preRiskEdit === s.key ? "完成" : "编辑" }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<NInput v-if="preRiskEdit === s.key" type="textarea" :bordered="false" v-model:value="preRiskText[s.key]" autofocus rows="6" />
|
<NInput v-if="preRiskEdit === s.key" type="textarea" :bordered="false" v-model:value="formOne[s.key]" autofocus rows="6" />
|
||||||
<p v-else class="whitespace-pre-wrap px-3 py-1.5 text-sm leading-relaxed text-slate-700">{{ preRiskText[s.key] }}</p>
|
<p v-else class="whitespace-pre-wrap px-3 py-1.5 text-sm leading-relaxed text-slate-700">{{ formOne[s.key] }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
|
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
|
||||||
AI 风险预分析仅供参考,用于申请阶段的快速研判;正式风险识别请前往「风险分析记录表」标签页,使用 HAZOP / JSA / 检查表法开展并留存记录。
|
AI 风险预分析仅供参考,用于申请阶段的快速研判;正式风险识别请前往「风险分析记录表」标签页,使用 HAZOP / JSA / 检查表法开展并留存记录。
|
||||||
@@ -886,8 +877,9 @@ const tabChange = (id: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ===== 变更申请表 ===== -->
|
<!-- ===== 变更申请表 ===== -->
|
||||||
<template v-if="tab === 'form' && form">
|
<template v-if="tab === 'form'">
|
||||||
<!-- 申请部门 / 申请人 -->
|
<!-- 申请部门 / 申请人 -->
|
||||||
<div class="grid gap-4 grid-cols-2">
|
<div class="grid gap-4 grid-cols-2">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
@@ -1532,14 +1524,14 @@ const tabChange = (id: string) => {
|
|||||||
<template v-if="tab === 'pre'">
|
<template v-if="tab === 'pre'">
|
||||||
<span class="text-xs text-slate-400">预识别阶段不设审批人与提交:完成识别与预分析后,请切换至「变更申请表」选择审批人并提交</span>
|
<span class="text-xs text-slate-400">预识别阶段不设审批人与提交:完成识别与预分析后,请切换至「变更申请表」选择审批人并提交</span>
|
||||||
<div class="ml-auto flex shrink-0 gap-2">
|
<div class="ml-auto flex shrink-0 gap-2">
|
||||||
<NButton ghost type="success" size="small" @click="genForm">
|
<NButton ghost type="success" size="small" :loading="generateLoading" :disabled="btnDisabled" @click="genForm">
|
||||||
<Icon icon="akar-icons:file" class="size-14px" /> 生成变更申请单
|
<Icon icon="akar-icons:file" class="size-14px" /> 生成变更申请单
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton ghost type="warning" size="small" @click="runRiskAnalysis" :disabled="analyzing">
|
<NButton ghost type="warning" size="small" @click="runRiskAnalysis" :disabled="btnDisabled">
|
||||||
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
||||||
<Icon v-else icon="mdi:shield-alert-outline" class="size-14px" />风险预分析
|
<Icon v-else icon="mdi:shield-alert-outline" class="size-14px" />风险预分析
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton type="primary" size="small" @click="saveDraft">保存</NButton>
|
<NButton type="primary" size="small" :disabled="btnDisabled" :loading="saveLoading" @click="saveDraft">保存</NButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, ref, watch } from 'vue'
|
||||||
|
import { NButton, NDataTable, NInput } from 'naive-ui'
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import RichTextInput from './RichTextInput.vue'
|
||||||
|
|
||||||
|
interface ChangeItem {
|
||||||
|
item: string
|
||||||
|
before_text: string
|
||||||
|
after_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: ChangeItem[]
|
||||||
|
editable?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:data', data: ChangeItem[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localData = ref<ChangeItem[]>([])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data,
|
||||||
|
(val: ChangeItem[]) => {
|
||||||
|
// 深度拷贝以避免直接修改 props,并确保响应式
|
||||||
|
localData.value = val ? JSON.parse(JSON.stringify(val)) : []
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
localData.value.push({ item: '新项目', before_text: '', after_text: '' })
|
||||||
|
emit('update:data', localData.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(idx: number) {
|
||||||
|
localData.value.splice(idx, 1)
|
||||||
|
emit('update:data', localData.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellChange() {
|
||||||
|
emit('update:data', localData.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed<DataTableColumns<ChangeItem>>(() => {
|
||||||
|
const cols: DataTableColumns<ChangeItem> = [
|
||||||
|
{
|
||||||
|
title: '变更项目',
|
||||||
|
key: 'project',
|
||||||
|
width: 100,
|
||||||
|
render: (row: ChangeItem) =>
|
||||||
|
props.editable
|
||||||
|
? h(NInput, {
|
||||||
|
'value': row.item,
|
||||||
|
'size': 'small',
|
||||||
|
'placeholder': '项目名称',
|
||||||
|
'onUpdate:value': (v: string) => {
|
||||||
|
row.item = v
|
||||||
|
onCellChange()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: h('span', null, row.item),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变更前',
|
||||||
|
key: 'before_text',
|
||||||
|
width: 200,
|
||||||
|
render: (row: ChangeItem) =>
|
||||||
|
props.editable
|
||||||
|
? h(RichTextInput, {
|
||||||
|
'modelValue': row.before_text,
|
||||||
|
'minRows': 1,
|
||||||
|
'placeholder': '变更前内容',
|
||||||
|
'onUpdate:modelValue': (v: string) => {
|
||||||
|
row.before_text = v
|
||||||
|
onCellChange()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: h('div', { class: 'cell-content', innerHTML: row.before_text }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变更后',
|
||||||
|
key: 'after_text',
|
||||||
|
width: 200,
|
||||||
|
render: (row: ChangeItem) =>
|
||||||
|
props.editable
|
||||||
|
? h(RichTextInput, {
|
||||||
|
'modelValue': row.after_text,
|
||||||
|
'minRows': 1,
|
||||||
|
'placeholder': '变更后内容',
|
||||||
|
'onUpdate:modelValue': (v: string) => {
|
||||||
|
row.after_text = v
|
||||||
|
onCellChange()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: h('div', { class: 'cell-content', innerHTML: row.after_text }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (props.editable) {
|
||||||
|
cols.push({
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 50,
|
||||||
|
align: 'center',
|
||||||
|
render: (_row: ChangeItem, rowIndex: number) =>
|
||||||
|
h(NButton, {
|
||||||
|
text: true,
|
||||||
|
size: 'tiny',
|
||||||
|
type: 'error',
|
||||||
|
onClick: () => removeRow(rowIndex),
|
||||||
|
}, () => h(Icon, { icon: 'line-md:close-circle', class: 'size-16px' })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="change-compare-table">
|
||||||
|
<NDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="localData"
|
||||||
|
:pagination="false"
|
||||||
|
size="small"
|
||||||
|
:bordered="true"
|
||||||
|
/>
|
||||||
|
<div v-if="editable" class="add-row-btn">
|
||||||
|
<NButton block @click="addRow">
|
||||||
|
<Icon icon="ic:round-plus" class="size-16px" /> 添加行
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.change-compare-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-data-table-td) {
|
||||||
|
padding: 8px !important;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-content {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1d2129;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖编辑器内部的一些基础背景避免在表格里太突兀 */
|
||||||
|
:deep(.rich-text-input) {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
// 变更后单元格蓝色高亮
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
|
||||||
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const props = defineProps<{ text: string; hl?: string[] }>();
|
|
||||||
interface Seg { t: string; hit: boolean }
|
|
||||||
const segs = computed<Seg[]>(() => {
|
|
||||||
const text = props.text;
|
|
||||||
if (!props.hl || props.hl.length === 0) return [{ t: text, hit: false }];
|
|
||||||
const out: Seg[] = [];
|
|
||||||
let rest = text;
|
|
||||||
const hls = [...props.hl].sort((a, b) => b.length - a.length);
|
|
||||||
while (rest.length) {
|
|
||||||
let idx = -1, kw = "";
|
|
||||||
for (const h of hls) {
|
|
||||||
const i = rest.indexOf(h);
|
|
||||||
if (i >= 0 && (idx < 0 || i < idx)) { idx = i; kw = h; }
|
|
||||||
}
|
|
||||||
if (idx < 0) { out.push({ t: rest, hit: false }); break; }
|
|
||||||
if (idx > 0) out.push({ t: rest.slice(0, idx), hit: false });
|
|
||||||
out.push({ t: kw, hit: true });
|
|
||||||
rest = rest.slice(idx + kw.length);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<span class="whitespace-pre-line"><template v-for="(s, i) in segs" :key="i">
|
|
||||||
<mark
|
|
||||||
v-if="s.hit" class="rounded bg-blue-100 px-0.5"
|
|
||||||
:style="{color: themeStore.themeColor}"
|
|
||||||
>{{ s.t }}</mark>
|
|
||||||
<template v-else>{{ s.t }}</template></template></span>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
placeholder?: string
|
||||||
|
minRows?: number
|
||||||
|
maxRows?: number
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: '',
|
||||||
|
minRows: 1,
|
||||||
|
maxRows: 100,
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const editorRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const isFocused = ref(false)
|
||||||
|
const showColorPicker = ref(false)
|
||||||
|
|
||||||
|
const LINE_HEIGHT = 22 // 单行高度(px)
|
||||||
|
|
||||||
|
const colorOptions = [
|
||||||
|
{ label: '红色', value: '#e8364e', bg: '#e8364e' },
|
||||||
|
{ label: '蓝色', value: '#1890ff', bg: '#1890ff' },
|
||||||
|
{ label: '橙色', value: '#fa8c16', bg: '#fa8c16' },
|
||||||
|
{ label: '绿色', value: '#52c41a', bg: '#52c41a' },
|
||||||
|
{ label: '紫色', value: '#722ed1', bg: '#722ed1' },
|
||||||
|
{ label: '默认', value: '', bg: '#333333' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 获取编辑器中去掉 HTML 标签后的纯文本,用于判断是否为空
|
||||||
|
function getPlainText(): string {
|
||||||
|
if (!editorRef.value)
|
||||||
|
return ''
|
||||||
|
return editorRef.value.innerText?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContent = ref(false)
|
||||||
|
|
||||||
|
// 设置编辑器内容(仅在内容确实不同时才更新,防止光标重置)
|
||||||
|
function setEditorHtml(html: string) {
|
||||||
|
if (!editorRef.value)
|
||||||
|
return
|
||||||
|
if (editorRef.value.innerHTML !== html)
|
||||||
|
editorRef.value.innerHTML = html || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入事件:同步内容到 modelValue
|
||||||
|
function onInput() {
|
||||||
|
if (!editorRef.value)
|
||||||
|
return
|
||||||
|
const html = editorRef.value.innerHTML || ''
|
||||||
|
// 如果内容只是 <br> 或空 div 则视为空
|
||||||
|
const plain = getPlainText()
|
||||||
|
hasContent.value = !!plain
|
||||||
|
emit('update:modelValue', plain ? html : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocus() {
|
||||||
|
isFocused.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
// 延迟关闭,让工具栏按钮的 mousedown 有时间执行
|
||||||
|
setTimeout(() => {
|
||||||
|
isFocused.value = false
|
||||||
|
showColorPicker.value = false
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== 工具栏操作 ===============
|
||||||
|
|
||||||
|
// 保存和恢复选区
|
||||||
|
let savedSelection: Range | null = null
|
||||||
|
|
||||||
|
function saveSelection() {
|
||||||
|
const sel = window.getSelection()
|
||||||
|
if (sel && sel.rangeCount > 0)
|
||||||
|
savedSelection = sel.getRangeAt(0).cloneRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSelection() {
|
||||||
|
if (savedSelection) {
|
||||||
|
const sel = window.getSelection()
|
||||||
|
if (sel) {
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(savedSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行粗体
|
||||||
|
function execBold(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
restoreSelection()
|
||||||
|
document.execCommand('bold', false)
|
||||||
|
editorRef.value?.focus()
|
||||||
|
onInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行文字颜色
|
||||||
|
function execColor(color: string, e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
restoreSelection()
|
||||||
|
if (color) {
|
||||||
|
document.execCommand('foreColor', false, color)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 移除颜色:先获取选区文本,删除后用无格式文本替换
|
||||||
|
document.execCommand('removeFormat', false)
|
||||||
|
}
|
||||||
|
showColorPicker.value = false
|
||||||
|
editorRef.value?.focus()
|
||||||
|
onInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleColorPicker(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveSelection()
|
||||||
|
showColorPicker.value = !showColorPicker.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具栏按钮 mousedown 时保存选区
|
||||||
|
function onToolbarMousedown(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听粘贴事件,只粘贴纯文本(保留已有的 HTML 标记,但不引入外部格式)
|
||||||
|
function onPaste(e: ClipboardEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
// 优先粘贴 HTML(保留粗体/颜色),fallback 纯文本
|
||||||
|
const html = e.clipboardData?.getData('text/html')
|
||||||
|
const text = e.clipboardData?.getData('text/plain') || ''
|
||||||
|
if (html) {
|
||||||
|
// 清理外部 HTML:只保留简单的格式标签
|
||||||
|
const cleanHtml = sanitizeHtml(html)
|
||||||
|
document.execCommand('insertHTML', false, cleanHtml)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
document.execCommand('insertText', false, text)
|
||||||
|
}
|
||||||
|
onInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简易 HTML 清洗:只保留 b/strong/span/em/i/br 标签
|
||||||
|
function sanitizeHtml(html: string): string {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = html
|
||||||
|
// 递归清洗
|
||||||
|
function cleanNode(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE)
|
||||||
|
return node.textContent || ''
|
||||||
|
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
const el = node as HTMLElement
|
||||||
|
const tag = el.tagName.toLowerCase()
|
||||||
|
const allowedTags = ['b', 'strong', 'span', 'em', 'i', 'br', 'u']
|
||||||
|
let inner = ''
|
||||||
|
el.childNodes.forEach((child) => {
|
||||||
|
inner += cleanNode(child)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tag === 'br')
|
||||||
|
return '<br>'
|
||||||
|
|
||||||
|
if (allowedTags.includes(tag)) {
|
||||||
|
// 保留 span 的 style 中的 color
|
||||||
|
if (tag === 'span' && el.style.color)
|
||||||
|
return `<span style="color:${el.style.color}">${inner}</span>`
|
||||||
|
|
||||||
|
if (tag === 'b' || tag === 'strong')
|
||||||
|
return `<b>${inner}</b>`
|
||||||
|
|
||||||
|
if (tag === 'em' || tag === 'i')
|
||||||
|
return `<em>${inner}</em>`
|
||||||
|
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
// 块级元素转换为换行
|
||||||
|
const blockTags = ['div', 'p', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
|
||||||
|
if (blockTags.includes(tag))
|
||||||
|
return `${inner}<br>`
|
||||||
|
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
return cleanNode(div)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听外部 modelValue 变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal:string) => {
|
||||||
|
if (!editorRef.value)
|
||||||
|
return
|
||||||
|
// 仅在编辑器不聚焦或内容确实不同时更新
|
||||||
|
if (!isFocused.value || editorRef.value.innerHTML !== newVal) {
|
||||||
|
setEditorHtml(newVal || '')
|
||||||
|
hasContent.value = !!getPlainText()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
setEditorHtml(props.modelValue || '')
|
||||||
|
hasContent.value = !!getPlainText()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 点击编辑器外部关闭颜色选择器
|
||||||
|
function handleDocClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('.rich-text-input'))
|
||||||
|
showColorPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleDocClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleDocClick)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="rich-text-input"
|
||||||
|
:class="{ 'is-focused': isFocused, 'is-disabled': disabled }"
|
||||||
|
>
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div v-show="isFocused && !disabled" class="rti-toolbar">
|
||||||
|
<button
|
||||||
|
class="rti-btn"
|
||||||
|
title="粗体 (Ctrl+B)"
|
||||||
|
@mousedown="onToolbarMousedown"
|
||||||
|
@click="execBold"
|
||||||
|
>
|
||||||
|
<b>B</b>
|
||||||
|
</button>
|
||||||
|
<div class="rti-color-wrap">
|
||||||
|
<button
|
||||||
|
class="rti-btn rti-btn-color"
|
||||||
|
title="文字颜色"
|
||||||
|
@mousedown="toggleColorPicker"
|
||||||
|
>
|
||||||
|
<span class="rti-color-icon">A</span>
|
||||||
|
<span class="rti-color-bar" />
|
||||||
|
</button>
|
||||||
|
<div v-show="showColorPicker" class="rti-color-dropdown">
|
||||||
|
<button
|
||||||
|
v-for="c in colorOptions"
|
||||||
|
:key="c.value"
|
||||||
|
class="rti-color-option"
|
||||||
|
:title="c.label"
|
||||||
|
@mousedown="(e: Event) => execColor(c.value, e)"
|
||||||
|
>
|
||||||
|
<span class="rti-color-dot" :style="{ background: c.bg }" />
|
||||||
|
<span class="rti-color-label">{{ c.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 编辑区域 -->
|
||||||
|
<div class="rti-editor-wrap">
|
||||||
|
<div
|
||||||
|
ref="editorRef"
|
||||||
|
class="rti-editor"
|
||||||
|
:contenteditable="!disabled"
|
||||||
|
:style="{
|
||||||
|
maxHeight: `${maxRows * LINE_HEIGHT}px`,
|
||||||
|
}"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="onFocus"
|
||||||
|
@blur="onBlur"
|
||||||
|
@paste="onPaste"
|
||||||
|
@mouseup="saveSelection"
|
||||||
|
@keyup="saveSelection"
|
||||||
|
></div>
|
||||||
|
<div v-if="!hasContent && !isFocused" class="rti-placeholder">
|
||||||
|
{{ placeholder }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-text-input {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #e5e8ef;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafbfc;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-input:hover {
|
||||||
|
border-color: #2080f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-input.is-focused {
|
||||||
|
border-color: #2080f0;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-input.is-disabled {
|
||||||
|
opacity: 0.8;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-input.is-disabled:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏 */
|
||||||
|
.rti-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fafbfc;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4e5969;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-btn:hover {
|
||||||
|
background: #e8f4ff;
|
||||||
|
color: #2080f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-btn b {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 颜色按钮 */
|
||||||
|
.rti-color-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-btn-color {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-color-icon {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-color-bar {
|
||||||
|
width: 14px;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #e8364e, #2080f0, #fa8c16, #52c41a);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 颜色下拉 */
|
||||||
|
.rti-color-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-color-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4e5969;
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-color-option:hover {
|
||||||
|
background: #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-color-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-color-label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑区域 */
|
||||||
|
.rti-editor-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-editor {
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: #1d2129;
|
||||||
|
overflow-y: auto;
|
||||||
|
outline: none;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-editor:empty::before {
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 粗体和颜色在编辑器内的渲染 */
|
||||||
|
.rti-editor :deep(b),
|
||||||
|
.rti-editor :deep(strong) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rti-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 10px;
|
||||||
|
color: #c9cdd4;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user