如何在Provider Hosted Add-in中嵌入SharePoint文件夹并保留原生功能(类Teams)?
刚好我之前做过类似的需求,要在Provider Hosted Add-in里嵌入特定文件夹又不脱离上下文,还得保留SP原生操作,完全可以通过SharePoint REST API + Office UI Fabric React来实现,思路和Teams里的通用文件夹逻辑一致——不用iframe,也不用重写GridView,直接复用SP的原生能力接口。
核心方案说明
我们的核心是:用REST API拉取文件夹的文件/子文件夹数据,用微软官方的Office UI Fabric React组件构建和SP原生风格一致的列表,然后通过调用SP的原生接口来实现「在Word中打开」「签出」「删除」这些操作,全程在Add-in上下文内完成,不会跳转。
1. 前端技术栈选择
推荐用React + Office UI Fabric React,因为这是微软官方适配SharePoint的组件库,样式和交互和SP原生完全对齐,减少自定义成本;如果不想用React,纯JS结合Fabric的Vanilla版本也可以,但React的组件复用性更好。
2. 关键实现步骤&代码示例
第一步:获取Add-in的访问令牌(C#后端辅助)
因为是Provider Hosted Add-in,需要先通过后端获取SP的访问令牌,传递给前端调用REST API:
// 在Provider Hosted Add-in的后端控制器中获取令牌 public ActionResult GetSPToken() { var spContext = SharePointContextProvider.Current.GetSharePointContext(HttpContext); using (var clientContext = spContext.CreateUserClientContextForSPHost()) { if (clientContext != null) { // 获取访问令牌 var token = spContext.UserAccessTokenForSPHost; return Json(new { AccessToken = token }, JsonRequestBehavior.AllowGet); } } return Json(new { Error = "Failed to get access token" }, JsonRequestBehavior.AllowGet); }
第二步:前端调用REST API获取文件夹内容(React示例)
先拉取目标文件夹的文件和子文件夹:
import * as React from 'react'; import { DetailsList, DetailsListLayoutMode, Selection, IColumn } from '@fluentui/react'; class SPFolderViewer extends React.Component { constructor(props) { super(props); this.state = { files: [], folders: [], accessToken: '', folderUrl: '/sites/YourSite/Shared%20Documents/YourTargetFolder' // 替换为你的目标文件夹相对路径 }; this.selection = new Selection({ onSelectionChanged: () => this.setState({ selectionDetails: this._getSelectionDetails() }) }); } componentDidMount() { // 从后端获取访问令牌 fetch('/Home/GetSPToken') .then(res => res.json()) .then(data => { this.setState({ accessToken: data.AccessToken }, () => { this.loadFolderContent(); }); }); } // 加载文件夹中的文件和子文件夹 loadFolderContent = () => { const { folderUrl, accessToken } = this.state; // 获取文件 fetch(`https://your-tenant.sharepoint.com/_api/web/GetFolderByServerRelativeUrl('${folderUrl}')/Files?$select=Name,ServerRelativeUrl,UniqueId,CheckedOutByUser,TimeLastModified`, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json;odata=nometadata' } }) .then(res => res.json()) .then(data => { this.setState({ files: data.value }); }); // 获取子文件夹(可选) fetch(`https://your-tenant.sharepoint.com/_api/web/GetFolderByServerRelativeUrl('${folderUrl}')/Folders?$select=Name,ServerRelativeUrl`, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json;odata=nometadata' } }) .then(res => res.json()) .then(data => { this.setState({ folders: data.value }); }); } // 构建列表列定义 getColumns = () => { const columns: IColumn[] = [ { key: 'name', name: '名称', fieldName: 'Name', minWidth: 200, isResizable: true }, { key: 'modified', name: '修改时间', fieldName: 'TimeLastModified', minWidth: 150, isResizable: true }, { key: 'checkedOut', name: '签出状态', fieldName: 'CheckedOutByUser/Title', minWidth: 150, isResizable: true }, { key: 'actions', name: '操作', minWidth: 200, onRender: (item) => this.renderActions(item) } ]; return columns; } // 渲染操作按钮 renderActions = (item) => { return ( <div style={{ display: 'flex', gap: '8px' }}> <button onClick={() => this.openInWord(item)}>在Word中打开</button> <button onClick={() => item.CheckedOutByUser ? this.checkInFile(item) : this.checkOutFile(item)}> {item.CheckedOutByUser ? '签入' : '签出'} </button> <button onClick={() => this.deleteFile(item)}>删除</button> </div> ); } // 在Word中打开(利用SP的WOPI框架) openInWord = (file) => { const fileGuid = file.UniqueId; const wopiUrl = `https://your-tenant.sharepoint.com/_layouts/15/WopiFrame.aspx?sourcedoc=${fileGuid}&action=edit`; // 在Add-in内用弹窗打开,不脱离上下文 window.open(wopiUrl, '_blank', 'width=1000,height=800'); } // 签出文件 checkOutFile = (file) => { const { accessToken } = this.state; fetch(`https://your-tenant.sharepoint.com/_api/web/GetFileByServerRelativeUrl('${file.ServerRelativeUrl}')/CheckOut()`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json;odata=nometadata' } }) .then(() => { this.loadFolderContent(); // 刷新列表 alert('文件已签出'); }); } // 签入文件 checkInFile = (file) => { const { accessToken } = this.state; fetch(`https://your-tenant.sharepoint.com/_api/web/GetFileByServerRelativeUrl('${file.ServerRelativeUrl}')/CheckIn(comment='签入更新')`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json;odata=nometadata' } }) .then(() => { this.loadFolderContent(); // 刷新列表 alert('文件已签入'); }); } // 删除文件 deleteFile = (file) => { if (confirm('确定要删除该文件吗?')) { const { accessToken } = this.state; fetch(`https://your-tenant.sharepoint.com/_api/web/GetFileByServerRelativeUrl('${file.ServerRelativeUrl}')/DeleteObject()`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json;odata=nometadata' } }) .then(() => { this.loadFolderContent(); // 刷新列表 alert('文件已删除'); }); } } render() { const { files, folders } = this.state; // 合并文件和文件夹到一个列表(可按SP原生逻辑分开显示) const allItems = [...folders, ...files]; return ( <div style={{ padding: '16px' }}> <h2>目标文件夹内容</h2> <DetailsList items={allItems} columns={this.getColumns()} setKey="set" layoutMode={DetailsListLayoutMode.justified} selection={this.selection} selectionPreservedOnEmptyClick={true} ariaLabel="文件夹内容列表" /> </div> ); } } export default SPFolderViewer;
3. 关键注意事项
- 权限配置:确保你的Provider Hosted Add-in在AppManifest.xml中请求了足够的权限(比如
Web级别的Read或Write权限,根据需要调整) - 令牌有效期:注意访问令牌的有效期,需要实现令牌刷新逻辑,避免调用REST时出现权限过期
- 体验一致性:可以自定义Office UI Fabric组件的样式,完全匹配SharePoint原生列表的视觉效果
- 子文件夹导航:如果需要支持进入子文件夹,只需要在点击文件夹时更新
folderUrl状态,重新调用loadFolderContent即可
替代方案:使用SPFx Web Part嵌入
如果你觉得在Provider Hosted Add-in里开发React组件麻烦,也可以开发一个SPFx Web Part实现相同的文件夹查看逻辑,然后在Provider Hosted Add-in中通过Client Web Part的方式嵌入这个SPFx组件——这种方式更轻量化,因为SPFx已经内置了SP上下文和权限处理,不用自己管理令牌。
内容的提问来源于stack exchange,提问作者Holger

