Appearance
第 3 步:实现订单列表、详情与保存返回
前提:已完成认证与菜单接入。本章把子应用现有 /list 和 /detail 示例替换为调用真实 HTTP 接口的订单页面,基座菜单保持 records → /list。完成后可打开 1001、1002 两个详情,编辑备注,保存后关闭当前详情并返回来源列表。
1. 本章修改清单
| 文件 | 操作 |
|---|---|
| order-app/src/apis/orders.ts | 新建业务请求及类型 |
| order-app/src/views/demo/index.vue | 替换为订单列表 |
| order-app/src/views/demo/modules/detail/index.vue | 替换为订单详情 |
| order-app/src/router/index.ts | 列表、详情都声明 order:read |
| my-host/src/platform/access.ts | 对订单应用的列表和详情检查 order:read |
沿用上一章两个项目的 utils/csrf.ts;main.ts、bootstrap.ts、App.vue、页签缓存和会话装配保持生成器提供的实现。本章路径中的 demo 是生成模板现有功能目录,页面内容已经替换为订单;之后重命名目录时同步修改 import 即可。
2. 新建业务 API
将orders.ts保存为 order-app/src/apis/orders.ts:
ts
import { http } from '../utils/requestUtils'
import { csrfHeaders } from '../utils/csrf'
export interface Order {
id: string
customer: string
amount: number
note: string
}
/** 示例 API 直接返回数据,不使用 {code,data} 包裹;凭据由模板已安装的会话动态读取。 */
export const listOrders = (signal: AbortSignal) => http.get<Order[]>('orders', { signal })
export const getOrder = (id: string, signal: AbortSignal) => http.get<Order>(`orders/${encodeURIComponent(id)}`, { signal })
/** 先拿 CSRF,再发送带当前会话凭据的写请求;失败不自动重试。 */
export async function saveOrder(id: string, note: string, signal: AbortSignal): Promise<Order> {
const headers = await csrfHeaders(signal)
return http.put<Order>(`orders/${encodeURIComponent(id)}`, { note }, { signal, headers })
}这里导入模板的请求实例,已经接上当前认证会话。嵌入时读取宿主凭据;独立时读取自己的凭据。请求路径写 orders,最终为 /api/orders;不要再叠加 /api,也不要为了取 Token 新建另一个全局请求实例。
当前教学接口直接返回数组/订单对象。真实服务使用业务包装结构时在此解包,真实接口的数据结构也应在项目 API 层验证。
3. 完整替换列表页
将 OrderList.vue 保存为 order-app/src/views/demo/index.vue:
vue
<script setup lang="ts">
import { onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMicroPage, useMicroSession, useMicroTab, usePermission } from '@axewo/fin-micro/vue'
import { listOrders, type Order } from '../../apis/orders'
import { toSafeError } from '../../utils/errorUtils'
const { bridge } = useMicroSession()
// 只有嵌入页签的页面子树有 MicroTab 注入;独立模式使用本应用 Router。
const sdk = bridge ? useMicroPage(bridge, useMicroTab()) : undefined
const router = useRouter()
const canRead = usePermission().can('order:read')
const rows = ref<Order[]>([])
const message = ref('')
const loading = ref(false)
let request: AbortController | undefined
/** 列表被缓存后,重新激活时读取最新备注;取消上一轮加载,避免旧响应覆盖新列表。 */
async function load(): Promise<void> {
request?.abort()
const current = request = new AbortController()
loading.value = true
message.value = ''
try {
const data = await listOrders(current.signal)
if (!current.signal.aborted) rows.value = data
} catch (error) {
if (!current.signal.aborted) message.value = toSafeError(error).message
} finally {
if (request === current) loading.value = false
}
}
// 路由视图异步加载时,父 KeepAlive 可能已经激活,首次请求必须在 mounted 发起。
// 再次激活重新拉取数据;首次 activated 若紧随 mounted,则跳过已在进行的加载。
onMounted(() => { void load() })
onActivated(() => { if (bridge && !loading.value) void load() })
onBeforeUnmount(() => request?.abort())
async function open(row: Order): Promise<void> {
if (!canRead.value) return
const route = `/detail?${new URLSearchParams({ id: row.id })}`
try {
if (!sdk || !bridge) { await router.push(route); return }
// openTab 返回 MicroTab,失败会拒绝;需要 committed/cancelled 状态时使用 operate。
await sdk.openTab({ appId: bridge.appId, tabId: `${bridge.appId}:detail:${row.id}`, title: `订单 ${row.id}`, route })
} catch (error) { message.value = toSafeError(error).message }
}
</script>
<template>
<section class="panel">
<h1>订单列表</h1>
<el-button :loading="loading" @click="load">刷新列表</el-button>
<p v-if="message" role="alert">{{ message }}</p>
<el-table :data="rows" v-loading="loading">
<el-table-column prop="id" label="订单号" />
<el-table-column prop="customer" label="客户" />
<el-table-column prop="amount" label="金额" />
<el-table-column prop="note" label="备注" />
<el-table-column label="操作"><template #default="{ row }">
<el-button :disabled="!canRead" @click="open(row)">查看 {{ row.id }}</el-button>
</template></el-table-column>
</el-table>
</section>
</template>相同订单使用同一个 tabId,不同订单使用不同 tabId。tabId 由业务决定,宿主产生的 key 不需要也不应自己构造。此例订单号就是业务身份;租户隔离的业务应将租户也纳入身份,并同时修改宿主准入规则。
sdk.openTab() 返回 MicroTab,异常时拒绝;sdk.back()、sdk.operate() 返回带 status 的导航结果,两者不能混用。独立运行时使用 router.push,不创建宿主页签。
4. 完整替换详情页
将 OrderDetail.vue 保存为 order-app/src/views/demo/modules/detail/index.vue:
vue
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMicroPage, useMicroSession, useMicroTab, usePermission, useUnsavedChanges } from '@axewo/fin-micro/vue'
import { getOrder, saveOrder } from '../../../../apis/orders'
import { toSafeError } from '../../../../utils/errorUtils'
const { bridge } = useMicroSession()
const sdk = bridge ? useMicroPage(bridge, useMicroTab()) : undefined
const route = useRoute()
const router = useRouter()
const canWrite = usePermission().can('order:write')
const id = ref('')
const note = ref('')
const loaded = ref(false)
const { dirty, setBaseline } = useUnsavedChanges(() => ({ note: note.value }), {
enabled: loaded,
label: () => `订单 ${id.value} 的备注`,
message: { content: '备注尚未保存,离开后将丢失。是否放弃修改?' },
})
const busy = ref(false)
const message = ref('')
const lifetime = new AbortController()
// 生成模板已安装统一确认:嵌入时按固定 tab.key 管理,独立时由路由守卫管理。
onBeforeUnmount(() => lifetime.abort())
// 缓存内的 useRoute 已按页签隔离;独立路由复用同一组件时,也要重新加载新的 ID。
watch(() => route.query.id, (value, _previous, onCleanup) => {
const request = new AbortController()
onCleanup(() => request.abort())
loaded.value = false
id.value = typeof value === 'string' && /^[1-9]\d{0,9}$/.test(value) ? value : ''
if (!id.value) { message.value = '订单号无效'; return }
message.value = ''
void getOrder(id.value, request.signal).then(order => {
if (!request.signal.aborted) { note.value = order.note; setBaseline(); loaded.value = true }
}).catch(error => { if (!request.signal.aborted) message.value = toSafeError(error).message })
}, { immediate: true })
/** 无参数返回来源;明确传列表目标时优先回列表。独立模式固定返回本地列表。 */
async function close(toList = false): Promise<void> {
try {
if (!sdk || !bridge) { await router.push('/list'); return }
const destination = toList ? { appId: bridge.appId, tabId: `${bridge.appId}:list`, title: '业务列表', route: '/list' } : undefined
const result = await sdk.back(destination)
if (result.status === 'stale') message.value = '页面状态已变化,请重试'
} catch (error) { message.value = toSafeError(error).message }
}
/** 只有后端保存成功才清除 dirty、关闭本页;失败保留输入,供用户处理后重试。 */
async function save(): Promise<void> {
if (busy.value || !loaded.value || !canWrite.value) return
busy.value = true
message.value = ''
const submitted = { note: note.value }
try {
await saveOrder(id.value, submitted.note, lifetime.signal)
if (lifetime.signal.aborted) return
setBaseline(submitted)
busy.value = false
await close()
} catch (error) {
if (!lifetime.signal.aborted) message.value = toSafeError(error).message
} finally { busy.value = false }
}
</script>
<template>
<section class="panel">
<h1>订单 {{ id }}</h1>
<p v-if="message" role="alert">{{ message }}</p>
<el-input v-model="note" aria-label="订单备注" type="textarea" :maxlength="500" :disabled="!loaded || busy || !canWrite" />
<p role="status">{{ dirty ? '存在未保存修改' : '无未保存修改' }}</p>
<div class="actions">
<el-button type="primary" :loading="busy" :disabled="!loaded || !canWrite" @click="save">保存并返回</el-button>
<el-button :disabled="busy" @click="close()">取消并返回来源</el-button>
<el-button :disabled="busy" @click="close(true)">关闭并回列表</el-button>
</div>
</section>
</template>页面里有三个独立动作:
| 按钮 | 行为 |
|---|---|
| 保存并返回 | 等待 PUT 成功,再清除 dirty,调用 back() 关闭本详情并返回来源 |
| 取消并返回来源 | back();未保存时先询问是否离开 |
| 关闭并回列表 | back(完整列表目标),显式目标优先于来源 |
保存失败保留输入与详情页。页面通过 useUnsavedChanges 自动判断修改,查询成功后 setBaseline(),保存成功后以提交快照更新基准;真正卸载时取消请求。关闭/刷新脏页由基座 FDialog 确认,保存期间本页按钮和输入禁用。草稿只存在当前页面实例,浏览器刷新和缓存淘汰后不会保留。
列表重新激活时重新请求数据,因此保存后能看到最新备注。只缓存组件不会自动刷新接口数据,这一步由业务负责。
5. 将本地路由改成订单权限
在 order-app/src/router/index.ts 中保留其他路由,仅将 /list 和 /detail 两条替换为:
ts
{
path: '/list',
component: () => import('../views/demo/index.vue'),
meta: { title: '订单列表', titleKey: 'app.list', icon: List, order: 10, permissions: ['order:read'] },
},
{
path: '/detail',
component: () => import('../views/demo/modules/detail/index.vue'),
meta: { title: '订单详情', titleKey: 'app.detail', permissions: ['order:read'], hidden: true, activeMenu: '/list' },
},List 图标在模板中已有导入。若希望独立菜单翻译也显示“订单列表”,在 order-app/src/locales/modules/app_zh-CN.ts 将 list 改为“订单列表”、detail 改为“订单详情”;app_en.ts 对应改为 Orders 和 Order details。文件名自动提供 app 命名空间,titleKey 存在时会优先使用映射词条。
6. 同步基座的页面准入
在 my-host/src/platform/access.ts 中,找到 const menus = menuLeaves(app.menus ?? []),在其后插入:
ts
// 文件顶部沿用模板已导入的 hasPermission;旧项目需补充此导入。
import { hasPermission } from '@axewo/fin-core/permission'
// order 应用的订单页面统一要求读取权限;其他应用保留各自策略。
if (input.appId === 'order' && !hasPermission({ authenticated: true, permissions: session.permissions }, 'order:read')) return false把原详情规则里的:
ts
hasPermission({ authenticated: true, permissions: session.permissions }, 'demo:read')替换为:
ts
hasPermission({ authenticated: true, permissions: session.permissions }, input.appId === 'order' ? 'order:read' : 'demo:read')保留完整详情格式、records 菜单归属和 tabId 校验,不能删成“只判断路径”。本教程仍使用 /detail?id=数字,因此 platform/menu.ts 的详情归属无需修改。教学 API 已返回 order:read/order:write,真实后端必须按实际用户返回对应权限,并校验订单资源归属。
这三个位置必须一致:后端返回的权限、子应用路由/按钮的权限、基座 canOpenRoute 的权限。meta.hidden 只隐藏菜单,不授予详情权限。
7. 跑通完整操作
保持 API、两个 Vite 服务运行。退出并重新登录基座,使当前会话使用最新权限与菜单。从“业务列表”打开订单列表。
- 应加载 1001、1002 两行;Network 中 GET /api/orders 为 200,带当前会话凭据。
- 点击“查看 1001”,新增“订单 1001”页签,详情读取 1001 的备注。
- 输入“已联系客户”,点击“保存并返回”。GET csrf、PUT orders/1001 成功后详情关闭,列表重新加载,备注显示新值。
- 再打开 1001,修改备注,点“取消并返回来源”;拒绝确认时保留页面,确认时关闭且不发送 PUT。
- 打开 1001 和 1002,验证标题、查询参数和草稿不串页;再打开同一订单复用同一业务页签。
- 在新无痕窗口直接访问 localhost:7101,独立登录后执行同样操作。独立模式以单页路由切换,保存后返回 /list。
教学服务只在本次进程内保留保存结果,重启后恢复初始订单。切换缓存页签保留草稿,不提示;关闭、刷新或替换脏页才确认,多个脏页批量关闭时合并提示。独立运行的路由切换会销毁当前表单,因此仍需确认。详细配置见未保存表单与统一确认。
新增其他业务页时如何套用
复制本章模式时逐项对齐:新建视图 → 在子应用 router 注册 → 后端 menus 添加可见入口 → 基座 access 增加必要规则 → 从页面 SDK 构造对应 route/tabId。隐藏详情还需在 platform/menu.ts 配置所属菜单。普通叶菜单精确匹配 route/tabId,详情不能靠后台加一个菜单绕过权限。
跨应用跳转需要额外注册目标应用并配置 canNavigate;完整的第二应用接入见扩展:接入第二个子应用。
下一步:第 4 步:构建、部署与验收。