Skip to content

第 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 服务运行。退出并重新登录基座,使当前会话使用最新权限与菜单。从“业务列表”打开订单列表。

  1. 应加载 1001、1002 两行;Network 中 GET /api/orders 为 200,带当前会话凭据。
  2. 点击“查看 1001”,新增“订单 1001”页签,详情读取 1001 的备注。
  3. 输入“已联系客户”,点击“保存并返回”。GET csrf、PUT orders/1001 成功后详情关闭,列表重新加载,备注显示新值。
  4. 再打开 1001,修改备注,点“取消并返回来源”;拒绝确认时保留页面,确认时关闭且不发送 PUT。
  5. 打开 1001 和 1002,验证标题、查询参数和草稿不串页;再打开同一订单复用同一业务页签。
  6. 在新无痕窗口直接访问 localhost:7101,独立登录后执行同样操作。独立模式以单页路由切换,保存后返回 /list。

教学服务只在本次进程内保留保存结果,重启后恢复初始订单。切换缓存页签保留草稿,不提示;关闭、刷新或替换脏页才确认,多个脏页批量关闭时合并提示。独立运行的路由切换会销毁当前表单,因此仍需确认。详细配置见未保存表单与统一确认。

新增其他业务页时如何套用 ​

复制本章模式时逐项对齐:新建视图 → 在子应用 router 注册 → 后端 menus 添加可见入口 → 基座 access 增加必要规则 → 从页面 SDK 构造对应 route/tabId。隐藏详情还需在 platform/menu.ts 配置所属菜单。普通叶菜单精确匹配 route/tabId,详情不能靠后台加一个菜单绕过权限。

跨应用跳转需要额外注册目标应用并配置 canNavigate;完整的第二应用接入见扩展:接入第二个子应用。

下一步:第 4 步:构建、部署与验收。