变更预识别板块修改

This commit is contained in:
2026-09-01 16:12:33 +08:00
parent 935df1d5e6
commit 378829da21
5 changed files with 832 additions and 233 deletions
+11 -5
View File
@@ -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;
+183 -191
View File
@@ -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>