A2UI v0.9
v0.9 是推荐协议:每条命令带 version: 'v0.9',并用显式的 createSurface / updateComponents / updateDataModel / deleteSurface。协议总览见 介绍。
下面八个演示使用 v0.9 命令,并与 React @ant-design/x-card 2.10 的同名 showcase 对齐交互场景、内容数据和状态流转。演示保留 Vue 的 XCard 用法(:on-action、useXCardField、registerCatalog),不强制复制 React 的 Bubble + XMarkdown 营销对话外壳。
代码演示
基础用法
咖啡预订场景:注册本地 catalog,选择日期和咖啡后确认订单。成功时删除预订 Surface,并创建包含咖啡、时间和金额的结果卡片。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
ActionButton,
BookForm,
CATALOGS,
CoffeeList,
CoffeeResultCard,
COFFEE_LIST,
DateTimeInput,
TextBlock,
} from './_shared';
registerCatalog(CATALOGS.coffee);
const HEADER_TEXT =
'Hello! Welcome to our online booking service 🎉\n\nPlease select your preferred date and time, and we will arrange the best seat for you. We look forward to seeing you!';
const ORDER_CONFIRMATION =
'✅ Booking confirmed! Your order has been confirmed. We look forward to seeing you!';
const commands = ref<XAgentCommand_v0_9[]>([]);
const headerText = ref('');
const confirmation = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createBookingCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'booking',
catalogId: 'local://coffee_booking_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'booking',
components: [
{ id: 'title', component: 'Text', text: 'Coffee Shop Order', variant: 'h1' },
{
id: 'datetime',
component: 'DateTimeInput',
status: { path: '/booking/status' },
action: {
event: {
name: 'select_date',
context: { time: { path: '/booking/res/time' } },
},
},
},
{
id: 'coffee_list',
component: 'CoffeeList',
list: { path: '/booking/list/data' },
description: { path: '/booking/list/description' },
status: { path: '/booking/status' },
action: {
event: {
name: 'select_coffee',
context: { coffee: { path: '/booking/res/coffee' } },
},
},
},
{ id: 'status-text', component: 'Text', status: { path: '/booking/status' }, variant: 'success' },
{ id: 'submit-text', component: 'Text', text: 'Confirm Order' },
{
id: 'submit-btn',
component: 'ActionButton',
child: 'submit-text',
variant: 'primary',
status: { path: '/booking/status' },
res: { path: '/booking/res' },
action: {
event: {
name: 'confirm_booking',
context: {
status: { path: '/booking/status' },
res: { path: '/booking/res' },
},
},
},
},
{
id: 'root',
component: 'BookForm',
children: ['title', 'datetime', 'coffee_list', 'status-text', 'submit-btn'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'booking',
path: '/booking',
value: {
res: { time: new Date().toISOString() },
list: {
description: 'Coffee List',
data: COFFEE_LIST,
},
},
},
},
];
const createResultCommand = (res: any): XAgentCommand_v0_9 => ({
version: 'v0.9',
updateComponents: {
surfaceId: 'result',
components: [
{
id: 'result-card',
component: 'CoffeeResultCard',
name: res?.coffee?.name,
description: res?.coffee?.description,
price: res?.coffee?.price,
tag: res?.coffee?.tag,
date: res?.time,
},
{ id: 'root', component: 'BookForm', children: ['result-card'] },
],
},
});
const startHeader = () => {
if (textTimer) window.clearInterval(textTimer);
headerText.value = '';
let index = 0;
textTimer = window.setInterval(() => {
index = Math.min(index + 3, HEADER_TEXT.length);
headerText.value = HEADER_TEXT.slice(0, index);
if (index >= HEADER_TEXT.length) {
if (textTimer) window.clearInterval(textTimer);
append(...createBookingCommands());
}
}, 32);
};
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'confirm_booking') return;
const { res, status } = (payload.context || {}) as Record<string, any>;
if (status !== 'success' || !res) return;
confirmation.value = ORDER_CONFIRMATION;
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'booking' } },
{
version: 'v0.9',
createSurface: {
surfaceId: 'result',
catalogId: 'local://coffee_booking_catalog.json',
},
},
createResultCommand(res),
);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
confirmation.value = '';
headerText.value = '';
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'booking' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'result' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startHeader();
}, 50);
};
onMounted(startHeader);
onUnmounted(() => {
if (textTimer) window.clearInterval(textTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">选择日期和咖啡后确认订单</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ headerText }}</div>
<p v-if="confirmation" style="margin: 10px 0; color: #52c41a; font-weight: 600">{{ confirmation }}</p>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ Text: TextBlock, DateTimeInput, BookForm, ActionButton, CoffeeList, CoffeeResultCard }"
:on-action="handleAction"
>
<XCard.Card id="booking" />
<XCard.Card id="result" />
</XCard.Box>
</div>
</template>
渐进式
欢迎文本流式输出后,商品数据分 4 批加载。loading-indicator Surface 展示进度,商品 Surface 通过连续的 updateComponents 从空列表扩展到 8 个商品。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
ALL_PROGRESS_PRODUCTS,
CATALOGS,
LoadingIndicator,
ProductCard,
ProductContainer,
PROGRESS_PRODUCT_BATCHES,
type Product,
} from './_shared';
registerCatalog(CATALOGS.progressive);
const WELCOME_TEXT =
'🎉 Welcome to the progressive product showcase!\n\nProduct data will be loaded in batches, 2 products at a time. You can observe how components are displayed progressively.';
const COMPLETE_TEXT = '✅ All products loaded! Total 8 products loaded.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const headerText = ref('');
const footerText = ref('');
const sessionKey = ref(0);
let headerTimer: number | undefined;
let footerTimer: number | undefined;
let batchTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createLoadingCommands = (progress: number, loading: boolean): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'loading-indicator',
catalogId: 'local://coffee_booking_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: 'LoadingIndicator',
progress,
total: ALL_PROGRESS_PRODUCTS.length,
loading,
},
],
},
},
];
const updateLoading = (progress: number, loading: boolean): XAgentCommand_v0_9 => ({
version: 'v0.9',
updateComponents: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: 'LoadingIndicator',
progress,
total: ALL_PROGRESS_PRODUCTS.length,
loading,
},
],
},
});
const updateProducts = (products: Product[]): XAgentCommand_v0_9 => ({
version: 'v0.9',
updateComponents: {
surfaceId: 'progressive-demo',
components: [
...products.map((product, index) => ({
id: `product-${product.id}`,
component: 'ProductCard',
name: product.name,
price: product.price,
category: product.category,
stock: product.stock,
tag: product.tag,
index,
})),
{
id: 'root',
component: 'ProductContainer',
children: products.map((product) => `product-${product.id}`),
},
],
},
});
const streamFooter = () => {
if (footerTimer) window.clearInterval(footerTimer);
footerText.value = '';
let index = 0;
footerTimer = window.setInterval(() => {
index = Math.min(index + 2, COMPLETE_TEXT.length);
footerText.value = COMPLETE_TEXT.slice(0, index);
if (index >= COMPLETE_TEXT.length && footerTimer) window.clearInterval(footerTimer);
}, 28);
};
const startBatches = () => {
let batchIndex = 0;
const loaded: Product[] = [];
const loadNext = () => {
if (batchIndex >= PROGRESS_PRODUCT_BATCHES.length) {
append(updateLoading(ALL_PROGRESS_PRODUCTS.length, false));
streamFooter();
return;
}
loaded.push(...PROGRESS_PRODUCT_BATCHES[batchIndex]);
append(updateLoading(loaded.length, batchIndex < PROGRESS_PRODUCT_BATCHES.length - 1));
batchTimer = window.setTimeout(() => {
append(updateProducts([...loaded]));
batchIndex += 1;
batchTimer = window.setTimeout(loadNext, 550);
}, 50);
};
loadNext();
};
const startFlow = () => {
if (headerTimer) window.clearInterval(headerTimer);
headerText.value = '';
let index = 0;
headerTimer = window.setInterval(() => {
index = Math.min(index + 3, WELCOME_TEXT.length);
headerText.value = WELCOME_TEXT.slice(0, index);
if (index >= WELCOME_TEXT.length) {
if (headerTimer) window.clearInterval(headerTimer);
append(
...createLoadingCommands(0, true),
{
version: 'v0.9',
createSurface: {
surfaceId: 'progressive-demo',
catalogId: 'local://coffee_booking_catalog.json',
},
},
);
startBatches();
}
}, 32);
};
const reload = () => {
if (headerTimer) window.clearInterval(headerTimer);
if (batchTimer) window.clearTimeout(batchTimer);
if (footerTimer) window.clearInterval(footerTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
footerText.value = '';
headerText.value = '';
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'loading-indicator' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'progressive-demo' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startFlow();
}, 50);
};
onMounted(startFlow);
onUnmounted(() => {
if (headerTimer) window.clearInterval(headerTimer);
if (footerTimer) window.clearInterval(footerTimer);
if (batchTimer) window.clearTimeout(batchTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">每批 2 个商品,通过 updateComponents 渐进追加</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ headerText }}</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ ProductCard, ProductContainer, LoadingIndicator }"
>
<XCard.Card id="loading-indicator" />
<XCard.Card id="progressive-demo" />
</XCard.Box>
<div
v-if="footerText"
style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #52c41a; margin-top: 10px"
>
{{ footerText }}
</div>
</div>
</template>
流式渲染
AI 食物推荐场景:介绍文本流式输出,随后进度条和 4 家餐厅逐步写入 dataModel。全部推荐完成后关闭 streaming 状态并显示完成提示。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CATALOGS,
LoadingProgress,
RESTAURANT_DATA,
RestaurantCard,
RestaurantList,
StreamContainer,
TextBlock,
type RestaurantItem,
} from './_shared';
registerCatalog(CATALOGS.streaming);
const INTRO_TEXT = `Hello! I'm your food recommendation assistant 🍽️
Based on your location and preferences, I'm selecting the best restaurants nearby for you...
Here are my recommendation criteria:
1. **Distance First**: Prioritizing restaurants within 15 minutes walking distance
2. **Quality Guaranteed**: Selecting only restaurants with ratings above 4.5
3. **Diverse Cuisines**: Covering Chinese, Japanese, Western, and more
Generating personalized recommendations for you...`;
const commands = ref<XAgentCommand_v0_9[]>([]);
const introText = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let progressTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createSurfaceCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'recommendation',
catalogId: 'local://restaurant_streaming_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'recommendation',
components: [
{ id: 'title', component: 'Text', text: 'AI Food Recommendations', variant: 'h1' },
{
id: 'progress',
component: 'LoadingProgress',
percent: { path: '/progress' },
status: { path: '/progressStatus' },
},
{
id: 'restaurant-list',
component: 'RestaurantList',
restaurants: { path: '/restaurants' },
loadingProgress: { path: '/progress' },
isStreaming: { path: '/isStreaming' },
},
{
id: 'root',
component: 'Container',
children: ['title', 'progress', 'restaurant-list'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/',
value: {
progress: 0,
progressStatus: 'active',
restaurants: [] as RestaurantItem[],
isStreaming: true,
},
},
},
];
const startProgress = () => {
let progress = 0;
let visibleCount = 0;
if (progressTimer) window.clearInterval(progressTimer);
progressTimer = window.setInterval(() => {
progress = Math.min(100, progress + 5);
const nextVisible = Math.ceil((progress / 100) * RESTAURANT_DATA.length);
if (nextVisible !== visibleCount) {
visibleCount = nextVisible;
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/restaurants',
value: RESTAURANT_DATA.slice(0, visibleCount),
},
});
}
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/progress',
value: progress,
},
});
if (progress >= 100) {
if (progressTimer) window.clearInterval(progressTimer);
append(
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/isStreaming',
value: false,
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/progressStatus',
value: 'success',
},
},
);
}
}, 85);
};
const startIntro = () => {
if (textTimer) window.clearInterval(textTimer);
introText.value = '';
let index = 0;
textTimer = window.setInterval(() => {
index = Math.min(index + 3, INTRO_TEXT.length);
introText.value = INTRO_TEXT.slice(0, index);
if (index >= INTRO_TEXT.length) {
if (textTimer) window.clearInterval(textTimer);
append(...createSurfaceCommands());
startProgress();
}
}, 24);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (progressTimer) window.clearInterval(progressTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
introText.value = '';
append({ version: 'v0.9', deleteSurface: { surfaceId: 'recommendation' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startIntro();
}, 50);
};
onMounted(startIntro);
onUnmounted(() => {
if (textTimer) window.clearInterval(textTimer);
if (progressTimer) window.clearInterval(progressTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Recommend Again
</button>
<span style="color: #8c8c8c; font-size: 13px">文本流式输出后,推荐卡片按进度逐步出现</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ introText }}</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
Text: TextBlock,
LoadingProgress,
RestaurantCard,
RestaurantList,
Container: StreamContainer,
}"
>
<XCard.Card id="recommendation" />
</XCard.Box>
</div>
</template>
嵌套交互
文件树、文件详情和手风琴面板共享同一个 Surface。点击文件后写入 /browser/selectedFile,详情卡与选中态同步更新,面板内容从 /browser/panels 读取。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
AccordionPanel,
CATALOGS,
FileDetailCard,
FileTree,
MainContainer,
PANEL_DATA,
TextBlock,
} from './_shared';
registerCatalog(CATALOGS.nested);
const HEADER_TEXT =
'Welcome to Project File Browser! Browse the project structure below. Click on files to view details, expand folders to explore nested contents.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createBrowserCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'browser',
catalogId: 'local://nested_interaction_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'browser',
components: [
{ id: 'title', component: 'Text', text: 'Project File Browser', variant: 'h1' },
{
id: 'file-tree',
component: 'FileTree',
selectedFile: { path: '/browser/selectedFile' },
action: {
event: {
name: 'select_file',
context: { selectedFile: { path: '/browser/selectedFile' } },
},
},
},
{ id: 'file-detail', component: 'FileDetailCard', file: { path: '/browser/selectedFile' } },
{ id: 'accordion-section', component: 'AccordionPanel', panels: { path: '/browser/panels' } },
{
id: 'root',
component: 'MainContainer',
children: ['title', 'file-tree', 'file-detail', 'accordion-section'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'browser',
path: '/browser',
value: { panels: PANEL_DATA },
},
},
];
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'select_file') return;
const selectedFile = (payload.context as Record<string, any>)?.selectedFile;
if (!selectedFile) return;
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'browser',
path: '/browser/selectedFile',
value: selectedFile,
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ version: 'v0.9', deleteSurface: { surfaceId: 'browser' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createBrowserCommands());
}, 50);
};
onMounted(() => append(...createBrowserCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">展开目录、选择文件,详情与手风琴面板共享 dataModel</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959; margin-bottom: 10px">
{{ HEADER_TEXT }}
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
Text: TextBlock,
FileTree,
FileDetailCard,
AccordionPanel,
MainContainer,
}"
:on-action="handleAction"
>
<XCard.Card id="browser" />
</XCard.Box>
</div>
</template>
多卡同步
商品列表、购物车和订单摘要三列布局,共享同一 /cart。支持加购、修改数量、删除、折扣与运费计算;结算后删除购物 Surface 并展示成功卡片。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CartCard,
CATALOGS,
CheckoutSuccessCard,
ColWrapper,
MultiCardContainer,
OrderSummaryCard,
ProductListCard,
SHOP_PRODUCTS,
type CartItem,
type Product,
} from './_shared';
registerCatalog(CATALOGS.cart);
const HEADER_TEXT =
'Welcome to Smart Shopping Cart! 🛒\n\nBrowse products, add them to your cart, and see real-time price calculations. Experience the power of multi-surface collaboration!';
const commands = ref<XAgentCommand_v0_9[]>([]);
const cart = ref<CartItem[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createShopCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'shop',
catalogId: 'local://cart_demo_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'shop',
components: [
{
id: 'product-list-card',
component: 'ProductListCard',
products: { path: '/products' },
cart: { path: '/cart' },
action: {
event: { name: 'add_to_cart', context: { product: {} } },
},
},
{
id: 'cart-card',
component: 'CartCard',
cart: { path: '/cart' },
updateAction: {
event: { name: 'update_quantity', context: { productId: {}, quantity: {} } },
},
removeAction: {
event: { name: 'remove_from_cart', context: { productId: {} } },
},
},
{
id: 'order-summary-card',
component: 'OrderSummaryCard',
cart: { path: '/cart' },
checkoutAction: {
event: { name: 'checkout', context: { orderData: {} } },
},
},
{ id: 'col-products', component: 'ColWrapper', span: 8, children: ['product-list-card'] },
{ id: 'col-cart', component: 'ColWrapper', span: 8, children: ['cart-card'] },
{ id: 'col-summary', component: 'ColWrapper', span: 8, children: ['order-summary-card'] },
{
id: 'root',
component: 'MultiCardContainer',
children: ['col-products', 'col-cart', 'col-summary'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'shop',
path: '/',
value: { products: SHOP_PRODUCTS, cart: [] },
},
},
];
const createCheckoutSuccess = (total: number, itemCount: number): XAgentCommand_v0_9[] => {
const orderNumber = `ORD-${Date.now().toString(36).toUpperCase()}`;
return [
{
version: 'v0.9',
createSurface: {
surfaceId: 'checkout-success',
catalogId: 'local://cart_demo_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'checkout-success',
components: [
{
id: 'root',
component: 'CheckoutSuccessCard',
total,
itemCount,
orderNumber,
},
],
},
},
];
};
const syncCart = () => {
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'shop',
path: '/cart',
value: cart.value,
},
});
};
const handleAction = (payload: ActionPayload) => {
const context = (payload.context || {}) as Record<string, any>;
switch (payload.name) {
case 'add_to_cart': {
const product = context.product as Product | undefined;
if (!product) return;
const existing = cart.value.find((item) => item.product.id === product.id);
cart.value = existing
? cart.value.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item,
)
: [...cart.value, { product, quantity: 1 }];
syncCart();
break;
}
case 'update_quantity': {
const { productId, quantity } = context;
if (!productId || !quantity) return;
cart.value = cart.value.map((item) =>
item.product.id === productId ? { ...item, quantity } : item,
);
syncCart();
break;
}
case 'remove_from_cart': {
const { productId } = context;
if (!productId) return;
cart.value = cart.value.filter((item) => item.product.id !== productId);
syncCart();
break;
}
case 'checkout': {
const total = Number(context.total) || 0;
const itemCount = Number(context.itemCount) || 0;
cart.value = [];
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'shop' } },
...createCheckoutSuccess(total, itemCount),
);
break;
}
case 'restart_demo': {
reload();
break;
}
}
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
cart.value = [];
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'shop' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'checkout-success' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createShopCommands());
}, 50);
};
onMounted(() => append(...createShopCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">商品、购物车、订单摘要共享同一份 /cart 数据</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959; margin-bottom: 10px">
{{ HEADER_TEXT }}
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
ProductListCard,
CartCard,
OrderSummaryCard,
ColWrapper,
MultiCardContainer,
CheckoutSuccessCard,
}"
:on-action="handleAction"
>
<XCard.Card id="shop" />
<XCard.Card id="checkout-success" />
</XCard.Box>
</div>
</template>
筛选搜索
10 个商品支持关键词、分类多选、价格区间和最低评分筛选。筛选项写入 /filters,结果列表延迟 300ms 读取同一份数据并刷新。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CATALOGS,
FilterContainer,
FilterPanel,
FilterProductList,
type FilterState,
} from './_shared';
registerCatalog(CATALOGS.filter);
const HEADER_TEXT =
'Welcome to Product Search! 🔍\n\nFind the perfect product by filtering by category, price, and rating. Results will update in real-time as you adjust filters.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createFilterCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'filter',
catalogId: 'local://filter_search_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'filter',
components: [
{
id: 'filter-panel',
component: 'FilterPanel',
selectedCategories: { path: '/filter/filters/categories' },
priceRange: { path: '/filter/filters/priceRange' },
minRating: { path: '/filter/filters/minRating' },
searchKeyword: { path: '/filter/filters/searchKeyword' },
action: {
event: {
name: 'update_filters',
context: { filters: { path: '/filter/filters' } },
},
},
},
{
id: 'product-list',
component: 'ProductList',
filters: { path: '/filter/filters' },
},
{
id: 'root',
component: 'FilterContainer',
children: ['filter-panel', 'product-list'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'filter',
path: '/filter',
value: {
filters: {
categories: [],
priceRange: [0, 1500],
minRating: 0,
searchKeyword: '',
},
},
},
},
];
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'update_filters') return;
const filters = (payload.context as Record<string, any>)?.filters as
| FilterState
| undefined;
if (!filters) return;
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'filter',
path: '/filter/filters',
value: filters,
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ version: 'v0.9', deleteSurface: { surfaceId: 'filter' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createFilterCommands());
}, 50);
};
onMounted(() => append(...createFilterCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">搜索、分类、价格区间和评分共同写入 /filter/filters</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959; margin-bottom: 10px">
{{ HEADER_TEXT }}
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
FilterPanel,
ProductList: FilterProductList,
FilterContainer,
}"
:on-action="handleAction"
>
<XCard.Card id="filter" />
</XCard.Box>
</div>
</template>
表单验证
多步骤注册表单:基本信息 → 安全设置 → 提交结果。字段使用 useXCardField 校验,并通过 Card 注入的 onDataChange 回写;最终提交后 Surface 被成功卡片替换。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import { CATALOGS, RegistrationForm, SuccessCard } from './_shared';
registerCatalog(CATALOGS.form);
const HEADER_TEXT =
'Welcome to register! 🎉\n\nPlease fill in your information to create an account. We will verify your information step by step.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const headerText = ref('');
const notice = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let resultTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createRegistrationCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'registration',
catalogId: 'local://form_validation_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'registration',
components: [
{
id: 'root',
component: 'RegistrationForm',
step: 0,
status: { path: '/registration/status' },
errorMessage: { path: '/registration/errorMessage' },
action: {
event: {
name: 'submit_step',
context: { formData: { path: '/registration/formData' } },
},
},
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'registration',
path: '/registration',
value: { status: '', errorMessage: '' },
},
},
];
const createResultCommands = (formData: any): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'result',
catalogId: 'local://form_validation_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'result',
components: [
{
id: 'root',
component: 'SuccessCard',
username: formData?.username,
email: formData?.email,
accountType: formData?.accountType ?? 'personal',
},
],
},
},
];
const startHeader = () => {
if (textTimer) window.clearInterval(textTimer);
headerText.value = '';
let index = 0;
textTimer = window.setInterval(() => {
index = Math.min(index + 3, HEADER_TEXT.length);
headerText.value = HEADER_TEXT.slice(0, index);
if (index >= HEADER_TEXT.length) {
if (textTimer) window.clearInterval(textTimer);
append(...createRegistrationCommands());
}
}, 32);
};
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'submit_step') return;
const formData = (payload.context as Record<string, any>)?.formData;
if (!formData) return;
if (!formData.submit) {
notice.value = `Step ${formData.step} completed`;
return;
}
notice.value = 'Registration successful!';
append({ version: 'v0.9', deleteSurface: { surfaceId: 'registration' } });
if (resultTimer) window.clearTimeout(resultTimer);
resultTimer = window.setTimeout(() => {
append(...createResultCommands(formData.values));
}, 300);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (resultTimer) window.clearTimeout(resultTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
notice.value = '';
headerText.value = '';
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'registration' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'result' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startHeader();
}, 50);
};
onMounted(startHeader);
onUnmounted(() => {
if (textTimer) window.clearInterval(textTimer);
if (resultTimer) window.clearTimeout(resultTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">逐步校验信息,最后生成注册结果卡片</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ headerText }}</div>
<p v-if="notice" style="margin: 10px 0; color: #1677ff; font-size: 13px">{{ notice }}</p>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ RegistrationForm, SuccessCard }"
:on-action="handleAction"
>
<XCard.Card id="registration" />
<XCard.Card id="result" />
</XCard.Box>
</div>
</template>
Action context 解析
暂无日志,请填写表单后提交
输入时把值写入 dataModel;提交按钮传空 context,Card 会按 action.event.context 里的 { path } 解析出实际值。下方日志保留最近四次回调,可直接观察解析结果。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CATALOGS,
FormCard,
FormInput,
SubmitButton,
Title,
} from './_shared';
registerCatalog(CATALOGS.action);
const commands = ref<XAgentCommand_v0_9[]>([]);
const logs = ref<string[]>([]);
const resolvedTip = ref('');
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createFormCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'form',
catalogId: 'local://action_resolve_catalog',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'form',
components: [
{ id: 'title', component: 'Title', text: '用户注册表单', variant: 'h2' },
{
id: 'username-input',
component: 'FormInput',
label: '用户名',
action: {
event: {
name: 'agent:send_context_text',
context: {
username: { path: '/form/username', label: '用户名' },
},
},
},
},
{
id: 'email-input',
component: 'FormInput',
label: '邮箱',
action: {
event: {
name: 'agent:send_context_text',
context: {
email: { path: '/form/email', label: '邮箱' },
},
},
},
},
{
id: 'submit-btn',
component: 'SubmitButton',
text: '提交表单',
action: {
event: {
name: 'agent:send_context_text',
context: {
username: { path: '/form/username', label: '用户名' },
email: { path: '/form/email', label: '邮箱' },
},
},
},
},
{
id: 'root',
component: 'FormCard',
children: ['title', 'username-input', 'email-input', 'submit-btn'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'form',
path: '/form',
value: { username: '', email: '' },
},
},
];
const handleAction = (payload: ActionPayload) => {
const log = `[${payload.name}]\n${JSON.stringify(payload.context, null, 2)}`;
logs.value = [log, ...logs.value].slice(0, 4);
resolvedTip.value = Object.values(payload.context || {}).some(
(value) => value && typeof value === 'object' && 'value' in value,
)
? 'Path 引用已自动解析为实际值'
: '';
};
const reset = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
logs.value = [];
resolvedTip.value = '';
append({ version: 'v0.9', deleteSurface: { surfaceId: 'form' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createFormCommands());
}, 50);
};
onMounted(() => append(...createFormCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #fff; border: 1px solid #d9d9d9; cursor: pointer"
@click="reset"
>
重置
</button>
<span style="color: #8c8c8c; font-size: 13px">填写表单后点击提交,观察 onAction 回调中 context 的变化</span>
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ FormCard, Title, FormInput, SubmitButton }"
:on-action="handleAction"
>
<XCard.Card id="form" />
</XCard.Box>
<p v-if="resolvedTip" style="margin: 12px 0 0; color: #52c41a; font-size: 13px">
{{ resolvedTip }}
</p>
<div style="margin-top: 16px">
<strong style="font-size: 13px">onAction 回调日志(path 引用已解析):</strong>
<div
v-if="logs.length"
style="margin-top: 8px; max-height: 220px; overflow: auto; border: 1px solid #f0f0f0; border-radius: 8px"
>
<pre
v-for="(log, index) in logs"
:key="index"
style="margin: 0; padding: 10px 12px; border-bottom: 1px solid #f5f5f5; font-size: 12px; white-space: pre-wrap"
>{{ log }}</pre>
</div>
<p v-else style="color: #8c8c8c; font-size: 13px">暂无日志,请填写表单后提交</p>
</div>
</div>
</template>
API
模板里请绑定 :on-action——这是 prop,不是 emit。
XCard.Box
Box 作为容器,管理 Card 实例和命令分发。默认插槽里放 XCard.Card。
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| commands | A2UI 命令对象 | (A2UICommand_v0_9 | XAgentCommand_v0_8)[] | [] |
| components | 自定义组件映射,名称必须以大写字母开头 | Record<string, Component> | - |
| onAction | Card 内部触发 action 时的回调 | (payload: ActionPayload) => void | - |
XCard.Card
用于渲染单个 Surface。
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| id | Surface ID,对应命令中的 surfaceId | string | - |
自定义组件会收到:
onAction(name, context):触发后由 Card 按版本执行extractDataUpdates,再解析 action context 中的 path 引用onDataChange(path, value):直接回写当前 surface 的 dataModel(双向绑定)
ActionPayload
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| name | 事件名称 | string | - |
| surfaceId | 触发该 action 的 surfaceId | string | - |
| context | 组件传递的上下文,已解析 path 引用 | Record<string, any> | - |
Action Context 中的 Path 引用解析
当组件触发 action 时,X Card 会把 context 里的 path 引用解析成实际值。
Path 引用格式
在 action 配置中使用 { path: string } 绑定 dataModel:
{
"id": "submit-btn",
"component": "Button",
"action": {
"event": {
"name": "agent:send_context_text",
"context": {
"username": { "path": "/form/username", "label": "用户名" },
"email": { "path": "/form/email", "label": "邮箱" }
}
}
}
}解析结果
{
"username": { "value": "张三", "label": "用户名" },
"email": { "value": "test@example.com", "label": "邮箱" }
}注意:
- 仅当 context 中的值本身是
{ path: "xxx" }时才转换 - 组件传入的实际值(如
{ value: "实际值" })不会被错误转换 - 会保留其他属性(如
label),只把path替换为value
A2UICommand_v0_9
XAgentCommand_v0_9 是 A2UICommand_v0_9 的别名。
CreateSurfaceCommand
创建新的 Surface。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| version | 版本号 | 'v0.9' | - |
| createSurface.surfaceId | Surface ID | string | - |
| createSurface.catalogId | 组件目录 URL 或本地标识 | string | - |
UpdateComponentsCommand
更新 Surface 上的组件。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| version | 版本号 | 'v0.9' | - |
| updateComponents.surfaceId | Surface ID | string | - |
| updateComponents.components | 组件列表 | BaseComponent_v0_9[] | - |
BaseComponent_v0_9
interface BaseComponent_v0_9 {
id: string
component: string
child?: string
children?: string[]
[key: string]: any | PathValue
}UpdateDataModelCommand
更新数据模型。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| version | 版本号 | 'v0.9' | - |
| updateDataModel.surfaceId | Surface ID | string | - |
| updateDataModel.path | 数据路径 | string | - |
| updateDataModel.value | 数据值 | any | - |
DeleteSurfaceCommand
删除 Surface。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| version | 版本号 | 'v0.9' | - |
| deleteSurface.surfaceId | Surface ID | string | - |
PathValue
interface PathValue {
path: string
}Catalog
interface Catalog {
$schema?: string
$id?: string
title?: string
description?: string
catalogId?: string
components?: Record<string, CatalogComponent>
functions?: Record<string, any>
$defs?: Record<string, any>
}CatalogComponent
interface CatalogComponent {
type: 'object'
allOf?: any[]
properties?: Record<string, any>
required?: string[]
[key: string]: any
}Catalog 相关方法
registerCatalog(catalog: Catalog): void
loadCatalog(catalogId: string): Promise<Catalog>
validateComponent(catalog: Catalog, componentName: string, componentProps: Record<string, any>): boolean
clearCatalogCache(): voiduseXCardField
Vue 侧额外提供的字段回写 / 校验 composable(表单验证演示在使用它)。
const field = useXCardField({
path: '/form/email',
onDataChange, // Card 注入
rules: [{ required: true }, { pattern: /.+@.+/, message: '邮箱格式不正确' }],
})
field.setValue(next)
field.validate()
field.error.value也导出纯函数 validateFieldValue(value, rules)。