前言
在实际项目中,经常会有用户提出这样的需求:希望能够在系统内直观地查看每一次功能更新或调整的内容。基于这个需求背景,我在D365加了个自定义的系统更新日志页面,用于集中展示系统的更新记录。本文将介绍整体思路以及具体实现方式。
效果展示
下面是最终实现的系统更新日志页面效果示例:

实现思路
整体设计思路比较简单:以日期作为更新日志的一级维度进行汇总,在每个日期下,再细分具体的更新内容,因此,在数据结构上只需要两张表即可:
- 系统更新日志:用于记录更新日期等汇总信息
- 系统更新日志明细:用于记录具体的更新内容
在此基础上,通过一个自定义的 HTML Web 资源,使用 FetchXML 获取数据并进行前端展示。
实现步骤
- 新建 “系统更新日志” 实体
- 新建 “系统更新日志明细” 实体
- 新建并配置自定义 HTML Web 资源
使用说明
- 创建 “系统更新日志” 实体
- 创建 “系统更新日志明细” 实体
- 新建
SystemUpdateLog_v1.htmlWeb 资源,并将下方代码复制进去
根据自己的实际情况,替换代码中实体的逻辑名称和字段的逻辑名称

前端依赖资源
页面依赖以下 3 个前端资源,需要提前上传为 Web 资源(可在我的 GitHub 仓库中获取):
- vue3.js
- element-plus.js
- element-plus.css
示例代码
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link href="../css/element-plus.css" rel="stylesheet" />
<title>xxxxxx System update log</title>
<style>
body {
margin: 0;
padding: 0;
}
.log-entry {
margin-bottom: 20px;
}
.system-section {
margin-left: 20px;
margin-top: 10px;
}
.update-item {
margin-left: 20px;
margin-bottom: 10px;
list-style-type: decimal;
}
.update-item span {
font-weight: bold;
}
h3,
h4 {
margin: 3px;
}
.el-timeline-item__timestamp {
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<div id="app">
<el-container>
<el-header>
<h2 style="color: #409EFF; ">xxxxxx System update log</h2>
</el-header>
<el-main>
<el-timeline>
<el-timeline-item v-for="log in fLogData" :key="log.log_id" :timestamp="log.date" placement="top" type="primary" hollow="true">
<el-card shadow="hover" style="margin-bottom: 20px;">
<div v-for="system in getSystems(log.details)" :key="system" class="system-section">
<h4>{{ system }}</h4>
<ul>
<li v-for="(detail, index) in getDetailsBySystem(log.details, system)" :key="index"
class="update-item">
<el-tag :type="getTagType(detail.updateType)">{{ detail.updateType}}</el-tag> {{ detail.content }}
</li>
</ul>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-main>
<el-footer></el-footer>
</el-container>
</div>
<script src="../js/vue3.js"></script>
<script src="../js/element-plus.js"></script>
<script>
const App = {
data() {
return {
Constants: {
Entities: {
log: "gdh_system_update_log",
logCollection: "gdh_system_update_logs",
logDetail: "gdh_system_update_log_detail",
logDetailCollection: "gdh_system_update_log_details",
},
Fields: {
// log entitie field
log_UpdateDate: "gdh_date",
log_Remark: "gdh_remark",
log_id: "gdh_system_update_logid",
// log detail entitie field
logDetail_SystemModel: "gdh_systemtype",
logDetail_UpdateType: "gdh_updatetype",
logDetail_Content: "gdh_content",
logDetail_RefLog: "gdh_system_update_log",
// common statecode
state: "statecode",
},
OptionSet: {
stateOption: {
Active: 0,
InActive: 1
}
}
},
fLogData: [],
};
},
created() {
this.init();
},
methods: {
init() {
let fetch = `
<fetch version="1.0" output-format="xml-platform" mapping="logical" distinct="false">
<entity name="${this.Constants.Entities.log}">
<attribute name="${this.Constants.Fields.log_id}" />
<attribute name="${this.Constants.Fields.log_UpdateDate}" />
<filter>
<condition attribute="${this.Constants.Fields.state}" operator="eq"
value="${this.Constants.OptionSet.stateOption.Active}" />
</filter>
<order attribute="${this.Constants.Fields.log_UpdateDate}" descending="true" />
<link-entity name="${this.Constants.Entities.logDetail}" from="${this.Constants.Fields.logDetail_RefLog}"
to="${this.Constants.Fields.log_id}" link-type="inner" alias="logDetail">
<attribute name="${this.Constants.Fields.logDetail_Content}" />
<attribute name="${this.Constants.Fields.logDetail_SystemModel}" />
<attribute name="${this.Constants.Fields.logDetail_UpdateType}" />
<filter>
<condition attribute="${this.Constants.Fields.state}" operator="eq"
value="${this.Constants.OptionSet.stateOption.Active}" />
</filter>
<order attribute="${this.Constants.Fields.logDetail_UpdateType}" descending="false" />
<order attribute="createdon" descending="true" />
</link-entity>
</entity>
</fetch>`;
let results = this.RetrieveRecordsUsingFetchXml(this.Constants.Entities.logCollection, encodeURI(fetch));
if (results && results.value && results.value.length > 0) {
this.fLogData =
results.value.reduce((acc, item) => {
const existingLog = acc.find(log => log["log_id"] === item[this.Constants.Fields.log_id]);
if (existingLog) {
existingLog.details.push({
systemModel: item[`logDetail.${this.Constants.Fields.logDetail_SystemModel}@OData.Community.Display.V1.FormattedValue`],
updateType: item[`logDetail.${this.Constants.Fields.logDetail_UpdateType}@OData.Community.Display.V1.FormattedValue`],
content: item[`logDetail.${this.Constants.Fields.logDetail_Content}`],
});
} else {
acc.push({
log_id: item[this.Constants.Fields.log_id],
date: item[this.Constants.Fields.log_UpdateDate + "@OData.Community.Display.V1.FormattedValue"],
details: [
{
systemModel: item[`logDetail.${this.Constants.Fields.logDetail_SystemModel}@OData.Community.Display.V1.FormattedValue`],
updateType: item[`logDetail.${this.Constants.Fields.logDetail_UpdateType}@OData.Community.Display.V1.FormattedValue`],
content: item[`logDetail.${this.Constants.Fields.logDetail_Content}`],
},
],
});
}
return acc;
}, []);
}
},
getSystems(details) {
const systems = [];
details.forEach(detail => {
if (!systems.includes(detail.systemModel)) {
systems.push(detail.systemModel);
}
});
return systems;
},
getDetailsBySystem(details, system) {
return details.filter(detail => detail.systemModel === system);
},
getTagType(updateType) {
const typeMap = {
'功能优化': 'primary',
'新添功能': 'success',
'数据更新': 'info',
'权限调整': 'warning',
'问题修复': 'danger',
'funcOptimization': 'primary',
'newFeature': 'success',
'dataUpdate': 'info',
'permAdjustment': 'warning',
'issueFix': 'danger'
};
return typeMap[updateType] || 'info';
},
// ============================================
/** [Begin] CRM function */
// ============================================
/** Common method to retrive records in 'Sync' mode based on custom query. */
RetrieveRecordsUsingFetchXml(lEntityName, lFetchXml) {
let lResponse = null;
let lXMLHttpRequest = new XMLHttpRequest();
lXMLHttpRequest.open("GET", this.GetClientUrl() + "/api/data/v9.2/" + lEntityName + "?fetchXml=" + lFetchXml, false);
lXMLHttpRequest.setRequestHeader("OData-MaxVersion", "4.0");
lXMLHttpRequest.setRequestHeader("OData-Version", "4.0");
lXMLHttpRequest.setRequestHeader("Accept", "application/json");
lXMLHttpRequest.setRequestHeader("Content-Type", "application/json; charset=utf-8");
lXMLHttpRequest.setRequestHeader("Prefer", "odata.include-annotations=\"*\"");
lXMLHttpRequest.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
lXMLHttpRequest.onreadystatechange = null;
lResponse = JSON.parse(this.response);
}
};
lXMLHttpRequest.send();
return lResponse;
},
GetClientUrl() {
let lGlobalContext = "";
try {
lGlobalContext = Xrm.Utility.getGlobalContext();
}
catch (e) {
lGlobalContext = parent.Xrm.Utility.getGlobalContext();
}
if (lGlobalContext !== null) {
return lGlobalContext.getClientUrl();
}
return null;
}
// ============================================
/** [End] CRM function */
// ============================================
}
};
const app = Vue.createApp(App);
app.use(ElementPlus);
app.mount("#app");
</script>
</body>
</html>
如果本文对你有所帮助,可以请我喝杯咖啡
(完)