feat: wire template stores and linkage

This commit is contained in:
zwt13703
2026-07-01 20:48:02 +08:00
parent 04b5536698
commit 60bdd5f268
10 changed files with 304 additions and 110 deletions
@@ -89,14 +89,14 @@
| 065 | 保存按钮 + loading 状态 + 成功/失败提示 | 前端 | 0.25d | ✅ 已完成 |
| 066 | 未选中区域时的空状态占位提示 | 前端 | 0.25d | ✅ 已完成 |
| | **Pinia Store** | | | |
| 067 | templateStore | 前端 | 0.5d | ⏳ 未完成 |
| 068 | selectionStore | 前端 | 0.25d | ⏳ 未完成 |
| 069 | uiStore | 前端 | 0.25d | ⏳ 未完成 |
| 067 | templateStore | 前端 | 0.5d | ✅ 已完成 |
| 068 | selectionStore | 前端 | 0.25d | ✅ 已完成 |
| 069 | uiStore | 前端 | 0.25d | ✅ 已完成 |
| | **三区联动** | | | |
| 070 | watch selectedBlockId → 配置面板加载 | 前端 | 0.5d | ⏳ 未完成 |
| 071 | watch selectedBlockId → 结构树节点高亮 | 前端 | 0.25d | ⏳ 未完成 |
| 072 | 页面初始化 loading(全页遮罩 + a-spin | 前端 | 0.25d | ⏳ 未完成 |
| 073 | 加载失败错误页 + 重新加载按钮 | 前端 | 0.25d | ⏳ 未完成 |
| 070 | watch selectedBlockId → 配置面板加载 | 前端 | 0.5d | ✅ 已完成 |
| 071 | watch selectedBlockId → 结构树节点高亮 | 前端 | 0.25d | ✅ 已完成 |
| 072 | 页面初始化 loading(全页遮罩 + a-spin | 前端 | 0.25d | ✅ 已完成 |
| 073 | 加载失败错误页 + 重新加载按钮 | 前端 | 0.25d | ✅ 已完成 |
| | **总计** | | **~18.25d** | |
---
+15
View File
@@ -137,3 +137,18 @@
9. 增加未选中区域时的空状态占位。
10. 执行前端构建验证。
- **执行结果**: 完成任务 058-066,配置面板具备完整基础表单能力。
## 会话 ID: 20260701-stores-linkage
- [2026-07-01 20:47:36]
- **执行原因**: 按任务清单继续完成 Pinia Store 和三区联动 067-073。
- **执行过程**:
1. 新增 `templateStore`,集中管理模板信息、预览 HTML、blocks、tree、configs、loading 和 error。
2. 新增 `selectionStore`,集中管理当前选中的 `selectedBlockId`
3. 新增 `uiStore`,集中管理标签显示、缩放比例和保存状态。
4. `TemplateEdit.vue` 接入初始化 loading、加载失败错误页和重新加载按钮。
5. `WordPreview.vue` 接入 store,点击预览块会更新全局选中区域。
6. `StructureTree.vue` 接入 store,选中节点会同步高亮并定位预览区。
7. `ConfigPanel.vue` watch `selectedBlockId`,切换区域时自动加载对应配置。
8. `TopToolbar.vue` 接入模板信息和保存状态。
9. 执行前端构建验证。
- **执行结果**: 完成任务 067-073,基础 MVP 任务清单 001-073 已全部标注完成。
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { computed, reactive, ref, watch } from "vue";
import { CloseOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { useSelectionStore } from "@/stores/selectionStore";
import { type BlockConfig, type RegionType, useTemplateStore } from "@/stores/templateStore";
import { useUiStore } from "@/stores/uiStore";
type RegionType = "ai_generate" | "manual" | "fixed" | "table";
const selectedBlock = ref({
block_id: "block_003",
text_preview: "一、经营概况",
});
const templateStore = useTemplateStore();
const selectionStore = useSelectionStore();
const uiStore = useUiStore();
const selectedBlock = computed(() => templateStore.blockById(selectionStore.selectedBlockId));
const saving = ref(false);
const dataSourceOptions = [
@@ -17,11 +18,11 @@ const dataSourceOptions = [
{ code: "research", name: "调研材料" },
];
const form = reactive({
region_name: selectedBlock.value.text_preview,
const form = reactive<BlockConfig>({
region_name: "",
region_type: "ai_generate" as RegionType,
data_sources: ["business_data"],
prompt: "请基于业务数据生成经营概况,突出核心指标变化和原因。",
data_sources: [],
prompt: "",
output_format: "formal_paragraph",
need_review: 1,
remark: "",
@@ -45,10 +46,29 @@ function sourceName(code: string) {
return dataSourceOptions.find((item) => item.code === code)?.name ?? code;
}
function loadSelectedConfig() {
const block = selectedBlock.value;
if (!block) return;
const config = templateStore.configById(block.block_id);
form.region_name = config?.region_name || block.text_preview;
form.region_type = config?.region_type || (block.block_type === "table" ? "table" : "ai_generate");
form.data_sources = [...(config?.data_sources ?? [])];
form.prompt = config?.prompt ?? "";
form.output_format = config?.output_format || "formal_paragraph";
form.need_review = config?.need_review ?? 1;
form.remark = config?.remark ?? "";
form.enabled = config?.enabled ?? 1;
}
async function saveConfig() {
const block = selectedBlock.value;
if (!block) return;
saving.value = true;
try {
await new Promise((resolve) => window.setTimeout(resolve, 500));
await templateStore.saveBlockConfig(block.block_id, { ...form, data_sources: [...form.data_sources] });
uiStore.markSaved();
message.success("配置已保存");
} catch {
message.error("配置保存失败");
@@ -56,6 +76,8 @@ async function saveConfig() {
saving.value = false;
}
}
watch(() => selectionStore.selectedBlockId, loadSelectedConfig, { immediate: true });
</script>
<template>
@@ -1,29 +1,12 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { reactive } from "vue";
import StructureTreeNode, { type StructureNode } from "./StructureTreeNode.vue";
import { useSelectionStore } from "@/stores/selectionStore";
import { useTemplateStore } from "@/stores/templateStore";
const nodes: StructureNode[] = [
{
id: "block_001",
name: "企业经营分析报告",
status: "done",
children: [
{ id: "block_002", name: "报告说明", status: "review" },
{
id: "block_003",
name: "一、经营概况",
status: "done",
children: [
{ id: "block_004", name: "经营概况段落", status: "empty" },
{ id: "block_005", name: "经营指标表", status: "disabled" },
],
},
],
},
];
const activeId = ref("block_003");
const templateStore = useTemplateStore();
const selectionStore = useSelectionStore();
const expandedKeys = reactive<Record<string, boolean>>({
block_001: true,
block_003: true,
@@ -34,7 +17,7 @@ function toggleNode(id: string) {
}
function selectNode(node: StructureNode) {
activeId.value = node.id;
selectionStore.selectBlock(node.id);
const target = document.querySelector<HTMLElement>(`[data-block-id="${node.id}"]`);
if (!target) return;
@@ -48,14 +31,14 @@ function selectNode(node: StructureNode) {
<aside class="structure-tree">
<header>
<span>文档结构</span>
<span class="count">{{ nodes.length }}</span>
<span class="count">{{ templateStore.blocks.length }}</span>
</header>
<ul class="tree-list">
<StructureTreeNode
v-for="node in nodes"
v-for="node in templateStore.tree"
:key="node.id"
:node="node"
:active-id="activeId"
:active-id="selectionStore.selectedBlockId"
:expanded-keys="expandedKeys"
@select="selectNode"
@toggle="toggleNode"
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import {
DownloadOutlined,
EyeOutlined,
@@ -7,21 +6,19 @@ import {
PlayCircleOutlined,
SaveOutlined,
} from "@ant-design/icons-vue";
import { useTemplateStore } from "@/stores/templateStore";
import { useUiStore } from "@/stores/uiStore";
const breadcrumbs = ["模板管理", "模板标注"];
const templateInfo = {
name: "企业经营分析报告模板",
version: "v1.0.0",
};
const isSaved = ref(true);
const saveStatusText = computed(() => (isSaved.value ? "已保存" : "未保存"));
const templateStore = useTemplateStore();
const uiStore = useUiStore();
function markDirty() {
isSaved.value = false;
uiStore.markDirty();
}
function saveTemplate() {
isSaved.value = true;
uiStore.markSaved();
}
</script>
@@ -32,9 +29,11 @@ function saveTemplate() {
<span v-for="item in breadcrumbs" :key="item">{{ item }}</span>
</div>
<div class="template-meta">
<strong>{{ templateInfo.name }}</strong>
<a-tag color="blue">{{ templateInfo.version }}</a-tag>
<span class="save-status" :class="{ dirty: !isSaved }">{{ saveStatusText }}</span>
<strong>{{ templateStore.template.name }}</strong>
<a-tag color="blue">{{ templateStore.template.version }}</a-tag>
<span class="save-status" :class="{ dirty: !uiStore.isSaved }">
{{ uiStore.saveStatusText }}
</span>
</div>
</div>
@@ -1,50 +1,15 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from "vue";
import { nextTick, onMounted, ref, watch } from "vue";
import { useSelectionStore } from "@/stores/selectionStore";
import { useTemplateStore } from "@/stores/templateStore";
import { useUiStore } from "@/stores/uiStore";
type RegionType = "ai_generate" | "manual" | "fixed" | "table" | "none";
const selectedBlockId = ref("block_003");
const showLabels = ref(true);
const zoom = ref(100);
const zoomOptions = [70, 100, 150];
const regionTypes: Record<string, RegionType> = {
block_001: "fixed",
block_002: "manual",
block_003: "ai_generate",
block_004: "ai_generate",
block_005: "table",
};
const previewHtml = `
<h1 data-block-id="block_001" style="margin:0 0 28px;text-align:center;font-size:20px;">企业经营分析报告</h1>
<p data-block-id="block_002" style="margin:8px 0;text-align:justify;text-indent:2em;">本报告用于展示 Word 模板解析后的 A4 预览效果,后续会由后端返回的 preview_html 驱动渲染。</p>
<h2 data-block-id="block_003" style="margin:24px 0 12px;padding-bottom:6px;border-bottom:2px solid #1a1d24;font-size:16px;">一、经营概况</h2>
<p data-block-id="block_004" style="margin:8px 0;text-align:justify;text-indent:2em;">点击预览区块后,左侧配置面板将展示对应区域的提示词、数据源和输出格式配置。</p>
<table data-block-id="block_005" style="width:100%;margin:14px 0;border-collapse:collapse;">
<thead>
<tr>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">指标</th>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">本期</th>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">同比</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding:7px 10px;border:1px solid #e0e2e6;">营业收入</td>
<td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td>
<td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td>
</tr>
<tr>
<td style="padding:7px 10px;border:1px solid #e0e2e6;">利润率</td>
<td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td>
<td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td>
</tr>
</tbody>
</table>
`;
const previewRef = ref<HTMLElement | null>(null);
const templateStore = useTemplateStore();
const selectionStore = useSelectionStore();
const uiStore = useUiStore();
function enhancePreviewBlocks() {
const root = previewRef.value;
@@ -52,11 +17,13 @@ function enhancePreviewBlocks() {
root.querySelectorAll<HTMLElement>("[data-block-id]").forEach((element) => {
const blockId = element.dataset.blockId ?? "";
const regionType = regionTypes[blockId] ?? "none";
const block = templateStore.blockById(blockId);
const config = templateStore.configById(blockId);
const regionType = config?.region_type ?? (block?.block_type === "table" ? "table" : "none");
element.classList.add("doc-block", `region-${regionType}`);
element.classList.toggle("active", blockId === selectedBlockId.value);
element.classList.toggle("active", blockId === selectionStore.selectedBlockId);
element.dataset.blockLabel = blockId;
element.dataset.labels = showLabels.value ? "on" : "off";
element.dataset.labels = uiStore.showBlockLabels ? "on" : "off";
});
}
@@ -65,19 +32,19 @@ async function selectBlock(event: MouseEvent) {
const block = target?.closest<HTMLElement>("[data-block-id]");
if (!block) return;
selectedBlockId.value = block.dataset.blockId ?? "";
selectionStore.selectBlock(block.dataset.blockId ?? "");
await nextTick();
enhancePreviewBlocks();
}
async function setZoom(value: number) {
zoom.value = value;
uiStore.setZoom(value);
await nextTick();
enhancePreviewBlocks();
}
async function toggleLabels(checked: boolean) {
showLabels.value = checked;
uiStore.setShowBlockLabels(checked);
await nextTick();
enhancePreviewBlocks();
}
@@ -85,19 +52,33 @@ async function toggleLabels(checked: boolean) {
onMounted(() => {
enhancePreviewBlocks();
});
watch(
() => [
templateStore.previewHtml,
selectionStore.selectedBlockId,
uiStore.showBlockLabels,
uiStore.zoom,
Object.keys(templateStore.configs).join(","),
],
async () => {
await nextTick();
enhancePreviewBlocks();
},
);
</script>
<template>
<section class="preview-shell">
<header class="preview-toolbar">
<a-switch size="small" :checked="showLabels" @change="toggleLabels" />
<a-switch size="small" :checked="uiStore.showBlockLabels" @change="toggleLabels" />
<span>显示标签</span>
<div class="zoom-group">
<button
v-for="item in zoomOptions"
:key="item"
type="button"
:class="{ active: zoom === item }"
:class="{ active: uiStore.zoom === item }"
@click="setZoom(item)"
>
{{ item }}%
@@ -109,9 +90,9 @@ onMounted(() => {
<div
ref="previewRef"
class="doc-page"
:style="{ transform: `scale(${zoom / 100})` }"
:style="{ transform: `scale(${uiStore.zoom / 100})` }"
@click="selectBlock"
v-html="previewHtml"
v-html="templateStore.previewHtml"
></div>
</div>
</section>
+12
View File
@@ -0,0 +1,12 @@
import { defineStore } from "pinia";
export const useSelectionStore = defineStore("selection", {
state: () => ({
selectedBlockId: "block_003",
}),
actions: {
selectBlock(blockId: string) {
this.selectedBlockId = blockId;
},
},
});
+131
View File
@@ -0,0 +1,131 @@
import { defineStore } from "pinia";
export type RegionType = "ai_generate" | "manual" | "fixed" | "table";
export type TemplateBlock = {
block_id: string;
block_type: "title" | "heading" | "paragraph" | "table";
text_preview: string;
level: number;
sort_order: number;
table_rows?: number;
table_cols?: number;
};
export type TreeNode = {
id: string;
name: string;
status: "done" | "review" | "disabled" | "empty";
children?: TreeNode[];
};
export type BlockConfig = {
region_name: string;
region_type: RegionType;
data_sources: string[];
prompt: string;
output_format: string;
need_review: number;
remark: string;
enabled: number;
};
const mockPreviewHtml = `
<h1 data-block-id="block_001" style="margin:0 0 28px;text-align:center;font-size:20px;">企业经营分析报告</h1>
<p data-block-id="block_002" style="margin:8px 0;text-align:justify;text-indent:2em;">本报告用于展示 Word 模板解析后的 A4 预览效果,后续会由后端返回的 preview_html 驱动渲染。</p>
<h2 data-block-id="block_003" style="margin:24px 0 12px;padding-bottom:6px;border-bottom:2px solid #1a1d24;font-size:16px;">一、经营概况</h2>
<p data-block-id="block_004" style="margin:8px 0;text-align:justify;text-indent:2em;">点击预览区块后,左侧配置面板将展示对应区域的提示词、数据源和输出格式配置。</p>
<table data-block-id="block_005" style="width:100%;margin:14px 0;border-collapse:collapse;">
<thead><tr>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">指标</th>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">本期</th>
<th style="padding:7px 10px;border:1px solid #e0e2e6;text-align:left;background:#f0f1f3;">同比</th>
</tr></thead>
<tbody>
<tr><td style="padding:7px 10px;border:1px solid #e0e2e6;">营业收入</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td></tr>
<tr><td style="padding:7px 10px;border:1px solid #e0e2e6;">利润率</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td><td style="padding:7px 10px;border:1px solid #e0e2e6;">-</td></tr>
</tbody>
</table>
`;
export const useTemplateStore = defineStore("template", {
state: () => ({
loading: false,
error: "",
template: {
id: 1,
name: "企业经营分析报告模板",
version: "v1.0.0",
},
previewHtml: mockPreviewHtml,
blocks: [
{ block_id: "block_001", block_type: "title", text_preview: "企业经营分析报告", level: 0, sort_order: 1 },
{ block_id: "block_002", block_type: "paragraph", text_preview: "报告说明", level: 0, sort_order: 2 },
{ block_id: "block_003", block_type: "heading", text_preview: "一、经营概况", level: 1, sort_order: 3 },
{ block_id: "block_004", block_type: "paragraph", text_preview: "经营概况段落", level: 0, sort_order: 4 },
{ block_id: "block_005", block_type: "table", text_preview: "经营指标表", level: 0, sort_order: 5, table_rows: 3, table_cols: 3 },
] as TemplateBlock[],
tree: [
{
id: "block_001",
name: "企业经营分析报告",
status: "done",
children: [
{ id: "block_002", name: "报告说明", status: "review" },
{
id: "block_003",
name: "一、经营概况",
status: "done",
children: [
{ id: "block_004", name: "经营概况段落", status: "empty" },
{ id: "block_005", name: "经营指标表", status: "disabled" },
],
},
],
},
] as TreeNode[],
configs: {
block_003: {
region_name: "一、经营概况",
region_type: "ai_generate",
data_sources: ["business_data"],
prompt: "请基于业务数据生成经营概况,突出核心指标变化和原因。",
output_format: "formal_paragraph",
need_review: 1,
remark: "",
enabled: 1,
},
block_005: {
region_name: "经营指标表",
region_type: "table",
data_sources: ["business_data"],
prompt: "",
output_format: "table_summary",
need_review: 1,
remark: "",
enabled: 0,
},
} as Record<string, BlockConfig>,
}),
getters: {
blockById: (state) => (blockId: string) => state.blocks.find((block) => block.block_id === blockId),
configById: (state) => (blockId: string) => state.configs[blockId],
},
actions: {
async loadTemplate() {
this.loading = true;
this.error = "";
try {
await new Promise((resolve) => window.setTimeout(resolve, 200));
} catch {
this.error = "模板加载失败";
} finally {
this.loading = false;
}
},
async saveBlockConfig(blockId: string, config: BlockConfig) {
await new Promise((resolve) => window.setTimeout(resolve, 400));
this.configs[blockId] = { ...config };
},
},
});
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from "pinia";
export const useUiStore = defineStore("ui", {
state: () => ({
showBlockLabels: true,
zoom: 100,
isSaved: true,
}),
getters: {
saveStatusText: (state) => (state.isSaved ? "已保存" : "未保存"),
},
actions: {
setZoom(value: number) {
this.zoom = value;
},
setShowBlockLabels(value: boolean) {
this.showBlockLabels = value;
},
markDirty() {
this.isSaved = false;
},
markSaved() {
this.isSaved = true;
},
},
});
+26 -1
View File
@@ -1,9 +1,17 @@
<script setup lang="ts">
import { onMounted } from "vue";
import ConfigPanel from "@/components/TemplateEdit/ConfigPanel.vue";
import LeftMenu from "@/components/TemplateEdit/LeftMenu.vue";
import StructureTree from "@/components/TemplateEdit/StructureTree.vue";
import TopToolbar from "@/components/TemplateEdit/TopToolbar.vue";
import WordPreview from "@/components/TemplateEdit/WordPreview.vue";
import { useTemplateStore } from "@/stores/templateStore";
const templateStore = useTemplateStore();
onMounted(() => {
templateStore.loadTemplate();
});
</script>
<template>
@@ -11,7 +19,17 @@ import WordPreview from "@/components/TemplateEdit/WordPreview.vue";
<LeftMenu />
<section class="edit-main">
<TopToolbar />
<div class="edit-workspace">
<div v-if="templateStore.loading" class="state-view">
<a-spin size="large" />
</div>
<div v-else-if="templateStore.error" class="state-view">
<a-result status="error" title="加载失败" :sub-title="templateStore.error">
<template #extra>
<a-button type="primary" @click="templateStore.loadTemplate()">重新加载</a-button>
</template>
</a-result>
</div>
<div v-else class="edit-workspace">
<ConfigPanel />
<WordPreview />
<StructureTree />
@@ -41,4 +59,11 @@ import WordPreview from "@/components/TemplateEdit/WordPreview.vue";
grid-template-columns: 380px minmax(520px, 1fr) 240px;
overflow: hidden;
}
.state-view {
display: grid;
min-height: 0;
place-items: center;
background: var(--c-bg);
}
</style>