diff --git a/src/components/business/fa-docx-preview/index.vue b/src/components/business/fa-docx-preview/index.vue
new file mode 100644
index 0000000..c1a3977
--- /dev/null
+++ b/src/components/business/fa-docx-preview/index.vue
@@ -0,0 +1,626 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/business/fa-docx-preview/useDocxOutline.ts b/src/components/business/fa-docx-preview/useDocxOutline.ts
new file mode 100644
index 0000000..d68ec1b
--- /dev/null
+++ b/src/components/business/fa-docx-preview/useDocxOutline.ts
@@ -0,0 +1,246 @@
+/**
+ * docx 大纲解析 composable
+ *
+ * @file-viewer/vue3 底层使用 docx-preview 渲染 docx,标题被渲染为带内联样式的
,
+ * 不存在语义化标签或样式类,因此其内置 collectDocumentAnchors 会把全部段落当作锚点,
+ * 无法形成"标题级"大纲。这里通过 JSZip 直接解压 docx 包,用 DOMParser 解析
+ * word/document.xml,提取带 Heading 样式 / outlineLvl 的段落,构建分层大纲。
+ *
+ * 兼容 WPS / 国产 Word:这类文档的 常使用纯数字 styleId
+ * (如 "2"、"3"),其含义定义在 word/styles.xml 中。因此需要同时解析 styles.xml,
+ * 建立 styleId → 标题层级 的映射,再在解析段落时查表。
+ */
+import { ref, shallowRef, type Ref, type ShallowRef } from "vue";
+import JSZip from "jszip";
+
+export interface DocxOutlineItem {
+ /** 唯一 id,同时会作为渲染后
上的 data-outline-id */
+ id: string;
+ /** 标题层级 1-9 */
+ level: number;
+ /** 标题文本 */
+ text: string;
+}
+
+export interface UseDocxOutlineResult {
+ /** 大纲条目(按文档顺序) */
+ outline: Ref;
+ /** 解析得到的 docx ArrayBuffer,可直接喂给 复用,避免二次请求 */
+ buffer: ShallowRef;
+ /** 解析中的 loading */
+ loading: Ref;
+ /** 解析错误 */
+ error: Ref;
+ /** 文件名(从 url 推断或外部传入) */
+ filename: Ref;
+ /** 加载并解析 docx,src 可为 url(string)或 ArrayBuffer */
+ load: (src: string | ArrayBuffer, name?: string) => Promise;
+}
+
+const HEADING_STYLE_RE = /^heading[\s_-]?([1-9])$/i;
+const HEADING_STYLE_CN_RE = /^标题\s?([1-9])$/;
+
+const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+
+function getAttr(el: Element, localName: string): string {
+ const ns = el.getAttributeNS(W_NS, localName);
+ if (ns) return ns;
+ return el.getAttribute(`w:${localName}`) || "";
+}
+
+function getHeadingLevelByName(name: string | null | undefined): number {
+ if (!name) return 0;
+ let m = HEADING_STYLE_RE.exec(name);
+ if (m) return Number(m[1]);
+ m = HEADING_STYLE_CN_RE.exec(name);
+ if (m) return Number(m[1]);
+ return 0;
+}
+
+function paragraphText(p: Element): string {
+ const ts = p.getElementsByTagNameNS(W_NS, "t");
+ let text = "";
+ for (let i = 0; i < ts.length; i++) {
+ const node = ts[i];
+ if (node) text += node.textContent || "";
+ }
+ return text.replace(/\s+/g, " ").trim();
+}
+
+interface StyleEntry {
+ styleId: string;
+ name: string;
+ basedOn: string;
+ outlineLvl: number | null;
+}
+
+function parseStylesXml(stylesXml: string): Map {
+ const doc = new DOMParser().parseFromString(stylesXml, "application/xml");
+ const styleEls = doc.getElementsByTagNameNS(W_NS, "style");
+ const map = new Map();
+ for (let i = 0; i < styleEls.length; i++) {
+ const style = styleEls[i];
+ if (!style) continue;
+ const type = getAttr(style, "type");
+ if (type !== "paragraph") continue;
+ const styleId = getAttr(style, "styleId");
+ if (!styleId) continue;
+ const nameEl = style.getElementsByTagNameNS(W_NS, "name")[0];
+ const name = nameEl ? getAttr(nameEl, "val") : "";
+ const basedOnEl = style.getElementsByTagNameNS(W_NS, "basedOn")[0];
+ const basedOn = basedOnEl ? getAttr(basedOnEl, "val") : "";
+ const outlineLvlEl = style.getElementsByTagNameNS(W_NS, "outlineLvl")[0];
+ let outlineLvl: number | null = null;
+ if (outlineLvlEl) {
+ const val = Number(getAttr(outlineLvlEl, "val"));
+ if (Number.isFinite(val)) outlineLvl = val;
+ }
+ map.set(styleId, { styleId, name, basedOn, outlineLvl });
+ }
+ return map;
+}
+
+function buildStyleHeadingMap(stylesXml: string): Map {
+ const entries = parseStylesXml(stylesXml);
+ const resolved = new Map();
+ function resolve(styleId: string, visited: Set): number {
+ const cached = resolved.get(styleId);
+ if (cached !== undefined) return cached;
+ if (visited.has(styleId)) return 0;
+ const entry = entries.get(styleId);
+ if (!entry) return 0;
+ visited.add(styleId);
+ if (entry.outlineLvl !== null) {
+ if (entry.outlineLvl >= 0 && entry.outlineLvl <= 8) {
+ const level = entry.outlineLvl + 1;
+ resolved.set(styleId, level);
+ return level;
+ }
+ resolved.set(styleId, 0);
+ return 0;
+ }
+ const byName = getHeadingLevelByName(entry.name);
+ if (byName > 0) {
+ resolved.set(styleId, byName);
+ return byName;
+ }
+ if (entry.basedOn) {
+ const inherited = resolve(entry.basedOn, visited);
+ if (inherited > 0) {
+ resolved.set(styleId, inherited);
+ return inherited;
+ }
+ }
+ resolved.set(styleId, 0);
+ return 0;
+ }
+ for (const id of entries.keys()) {
+ resolve(id, new Set());
+ }
+ return resolved;
+}
+
+export function parseDocxOutline(
+ documentXml: string,
+ styleHeadingMap?: Map
+): DocxOutlineItem[] {
+ const doc = new DOMParser().parseFromString(documentXml, "application/xml");
+ const paragraphs = doc.getElementsByTagNameNS(W_NS, "p");
+ const items: DocxOutlineItem[] = [];
+ for (let i = 0; i < paragraphs.length; i++) {
+ const p = paragraphs[i];
+ if (!p) continue;
+ const pPr = p.getElementsByTagNameNS(W_NS, "pPr")[0];
+ if (!pPr) continue;
+ let level = 0;
+ const pStyle = pPr.getElementsByTagNameNS(W_NS, "pStyle")[0];
+ if (pStyle) {
+ const styleVal = getAttr(pStyle, "val");
+ if (styleVal) {
+ if (styleHeadingMap && styleHeadingMap.has(styleVal)) {
+ level = styleHeadingMap.get(styleVal) || 0;
+ } else {
+ level = getHeadingLevelByName(styleVal);
+ }
+ }
+ }
+ if (level === 0) {
+ const outlineLvl = pPr.getElementsByTagNameNS(W_NS, "outlineLvl")[0];
+ if (outlineLvl) {
+ const val = Number(getAttr(outlineLvl, "val"));
+ if (Number.isFinite(val) && val >= 0 && val <= 8) {
+ level = val + 1;
+ }
+ }
+ }
+ if (level === 0) continue;
+ const text = paragraphText(p);
+ if (!text) continue;
+ items.push({
+ id: `docx-outline-${items.length + 1}`,
+ level,
+ text,
+ });
+ }
+ return items;
+}
+
+export function useDocxOutline(): UseDocxOutlineResult {
+ const outline = ref([]);
+ const buffer = shallowRef(null);
+ const loading = ref(false);
+ const error = ref(null);
+ const filename = ref("");
+
+ async function parseBuffer(arrayBuffer: ArrayBuffer, name?: string) {
+ if (name) filename.value = name;
+ const zip = await JSZip.loadAsync(arrayBuffer);
+ const documentFile = zip.file("word/document.xml");
+ if (!documentFile) {
+ throw new Error("docx 包内未找到 word/document.xml");
+ }
+ const documentXml = await documentFile.async("string");
+ const stylesFile = zip.file("word/styles.xml");
+ const stylesXml = stylesFile ? await stylesFile.async("string") : "";
+ const styleHeadingMap = stylesXml
+ ? buildStyleHeadingMap(stylesXml)
+ : new Map();
+ outline.value = parseDocxOutline(documentXml, styleHeadingMap);
+ }
+
+ async function load(src: string | ArrayBuffer, name?: string): Promise {
+ loading.value = true;
+ error.value = null;
+ outline.value = [];
+ buffer.value = null;
+ try {
+ if (typeof src === "string") {
+ const url = src;
+ if (!name) {
+ const u = url.split("/").pop() || "";
+ filename.value = decodeURIComponent(u.split("?")[0] || "document.docx");
+ } else {
+ filename.value = name;
+ }
+ const resp = await fetch(url);
+ if (!resp.ok) {
+ throw new Error(`加载 docx 失败:${resp.status} ${resp.statusText}`);
+ }
+ const arrayBuffer = await resp.arrayBuffer();
+ buffer.value = arrayBuffer;
+ await parseBuffer(arrayBuffer, filename.value);
+ } else {
+ buffer.value = src;
+ filename.value = name || "document.docx";
+ await parseBuffer(src, filename.value);
+ }
+ } catch (e: any) {
+ error.value = e instanceof Error ? e : new Error(String(e));
+ outline.value = [];
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ return { outline, buffer, loading, error, filename, load };
+}