You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Provider Hosted Add-in中嵌入SharePoint文件夹并保留原生功能(类Teams)?

实现Provider Hosted Add-in中嵌入SharePoint文件夹(保留原生功能)

刚好我之前做过类似的需求,要在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.14 07:04:47