A2UI v0.8
v0.8 是兼容协议:命令没有 version 字段,首次更新时会隐式创建 Surface。新项目请使用 A2UI v0.9。
下面八个演示与 React @ant-design/x-card 2.10 的 v0.8 showcase 对齐:咖啡预订、渐进加载、流式推荐、文件浏览器、多卡片购物车、筛选搜索、分步表单和 action context 解析。每个场景都使用原生 v0.8 命令(surfaceUpdate / dataModelUpdate / beginRendering),而不是复用 v0.9 的组件树。
代码演示
基础用法
咖啡预订场景:选择日期与咖啡后确认订单。命令按 surfaceUpdate → dataModelUpdate → beginRendering 顺序执行,成功后再删除预订 Surface 并渲染结果卡片。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
ActionButton,
BookForm,
CoffeeList,
CoffeeResultCard,
DateTimeInput,
TextBlock,
} from './_shared';
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_8[]>([]);
const headerText = ref('');
const confirmation = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createBookingCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'booking',
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'Coffee Shop Order' },
variant: { literalString: 'h1' },
},
},
},
{
id: 'datetime',
component: {
DateTimeInput: {
status: { path: '/status' },
action: {
name: 'select_date',
context: [{ key: 'time', value: { path: '/res/time' } }],
},
},
},
},
{
id: 'coffee_list',
component: {
CoffeeList: {
description: { literalString: 'Please select your favorite coffee' },
status: { path: '/status' },
action: {
name: 'select_coffee',
context: [{ key: 'coffee', value: { path: '/res/coffee' } }],
},
},
},
},
{
id: 'status-text',
component: {
Text: {
status: { path: '/status' },
variant: { literalString: 'success' },
},
},
},
{
id: 'submit-text',
component: { Text: { text: { literalString: 'Confirm Order' } } },
},
{
id: 'submit-btn',
component: {
ActionButton: {
child: 'submit-text',
variant: { literalString: 'primary' },
status: { path: '/status' },
res: { path: '/res' },
action: {
name: 'confirm_booking',
context: [
{ key: 'status', value: { path: '/status' } },
{ key: 'res', value: { path: '/res' } },
],
},
},
},
},
{
id: 'root',
component: {
BookForm: {
children: {
explicitList: ['title', 'datetime', 'coffee_list', 'status-text', 'submit-btn'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'booking',
contents: [
{
key: 'res',
valueMap: [{ key: 'time', valueString: new Date().toISOString() }],
},
],
},
},
{ beginRendering: { surfaceId: 'booking', root: 'root' } },
];
const createResultCommands = (res: any): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'result',
components: [
{
id: 'result-card',
component: {
CoffeeResultCard: {
name: { literalString: res?.coffee?.name ?? '' },
description: { literalString: res?.coffee?.description ?? '' },
price: { literalString: String(res?.coffee?.price ?? '') },
tag: { literalString: res?.coffee?.tag ?? '' },
date: { literalString: res?.time ?? '' },
},
},
},
{
id: 'root',
component: { BookForm: { child: 'result-card' } },
},
],
},
},
{ beginRendering: { surfaceId: 'result', root: 'root' } },
];
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(
{ deleteSurface: { surfaceId: 'booking' } },
...createResultCommands(res),
);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
confirmation.value = '';
headerText.value = '';
append(
{ deleteSurface: { surfaceId: 'booking' } },
{ 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">v0.8 surfaceUpdate + dataModelUpdate + beginRendering</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>
渐进式
两个 Surface 协同:loading-indicator 展示进度,商品 Surface 每批追加 2 个商品。后续 surfaceUpdate 会替换已缓存的组件树。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
ALL_PROGRESS_PRODUCTS,
LoadingIndicator,
ProductCard,
ProductContainer,
PROGRESS_PRODUCT_BATCHES,
type Product,
} from './_shared';
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_8[]>([]);
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_8[]) => {
commands.value = [...commands.value, ...next];
};
const updateLoading = (progress: number, loading: boolean): XAgentCommand_v0_8 => ({
surfaceUpdate: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: {
LoadingIndicator: {
progress: { literalString: String(progress) },
total: { literalString: String(ALL_PROGRESS_PRODUCTS.length) },
loading: { literalString: String(loading) },
},
},
},
],
},
});
const updateProducts = (products: Product[]): XAgentCommand_v0_8 => ({
surfaceUpdate: {
surfaceId: 'progressive-demo',
components: [
...products.map((product, index) => ({
id: `product-${product.id}`,
component: {
ProductCard: {
name: { literalString: product.name },
price: { literalString: String(product.price) },
category: { literalString: product.category },
stock: { literalString: String(product.stock) },
tag: product.tag ? { literalString: product.tag } : undefined,
index: { literalString: String(index) },
},
},
})),
{
id: 'root',
component: {
ProductContainer: {
children: { explicitList: products.map((product) => `product-${product.id}`) },
},
},
},
],
},
});
const createInitialCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: {
LoadingIndicator: {
progress: { literalString: '0' },
total: { literalString: String(ALL_PROGRESS_PRODUCTS.length) },
loading: { literalString: 'true' },
},
},
},
],
},
},
{ beginRendering: { surfaceId: 'loading-indicator', root: 'root' } },
updateProducts([]),
{ beginRendering: { surfaceId: 'progressive-demo', root: 'root' } },
];
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 startHeader = () => {
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(...createInitialCommands());
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(
{ deleteSurface: { surfaceId: 'loading-indicator' } },
{ deleteSurface: { surfaceId: 'progressive-demo' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startHeader();
}, 50);
};
onMounted(startHeader);
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">v0.8 surfaceUpdate 渐进更新两个 Surface</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>
流式渲染
食物推荐场景。介绍文本先流式输出,随后用 dataModelUpdate 逐步写入进度和餐厅列表,推荐卡片随进度出现并完成收尾。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
LoadingProgress,
RESTAURANT_DATA,
RestaurantCard,
RestaurantList,
StreamContainer,
TextBlock,
type RestaurantItem,
} from './_shared';
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_8[]>([]);
const introText = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let progressTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const progressUpdate = (percent: number): XAgentCommand_v0_8 => ({
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [{ key: 'progress', valueString: String(percent) }],
},
});
const restaurantsUpdate = (restaurants: RestaurantItem[]): XAgentCommand_v0_8 => ({
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [{ key: 'restaurants', valueString: JSON.stringify(restaurants) }],
},
});
const createInitialCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'recommendation',
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'AI Food Recommendations' },
variant: { literalString: 'h1' },
},
},
},
{
id: 'progress',
component: {
LoadingProgress: {
percent: { path: '/progress' },
status: { literalString: 'active' },
},
},
},
{
id: 'restaurant-list',
component: {
RestaurantList: {
restaurants: { path: '/restaurants' },
loadingProgress: { path: '/progress' },
isStreaming: { path: '/isStreaming' },
},
},
},
{
id: 'root',
component: {
Container: {
children: { explicitList: ['title', 'progress', 'restaurant-list'] },
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [
{ key: 'progress', valueString: '0' },
{ key: 'isStreaming', valueString: 'true' },
{ key: 'restaurants', valueString: '[]' },
],
},
},
{ beginRendering: { surfaceId: 'recommendation', root: 'root' } },
];
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(restaurantsUpdate(RESTAURANT_DATA.slice(0, visibleCount)));
}
append(progressUpdate(progress));
if (progress >= 100) {
if (progressTimer) window.clearInterval(progressTimer);
append({
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [{ key: 'isStreaming', valueString: 'false' }],
},
});
}
}, 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(...createInitialCommands());
startProgress();
}
}, 24);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (progressTimer) window.clearInterval(progressTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
introText.value = '';
append({ 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"
>
Refresh Recommendations
</button>
<span style="color: #8c8c8c; font-size: 13px">v0.8 dataModelUpdate 驱动进度与推荐列表</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>
嵌套交互
文件树 + 文件详情 + 手风琴面板。点击文件后把 selectedFile 写回 dataModel,详情卡通过 path 绑定同步刷新;面板数据用 valueMap 以 JSON 字符串存储。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
AccordionPanel,
FileDetailCard,
FileTree,
MainContainer,
PANEL_DATA,
TextBlock,
} from './_shared';
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_8[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createBrowserCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'browser',
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'Project File Browser' },
variant: { literalString: 'h1' },
},
},
},
{
id: 'file-tree',
component: {
FileTree: {
action: {
name: 'select_file',
context: [{ key: 'selectedFile', value: { path: '/browser/selectedFile' } }],
},
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: {
explicitList: ['title', 'file-tree', 'file-detail', 'accordion-section'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'browser',
contents: [
{
key: 'panels',
valueMap: PANEL_DATA.map((panel) => ({
key: panel.id,
valueString: JSON.stringify(panel),
})),
},
],
},
},
{ beginRendering: { surfaceId: 'browser', root: 'root' } },
];
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'select_file') return;
const selectedFile = (payload.context as Record<string, any>)?.selectedFile;
if (!selectedFile) return;
append({
dataModelUpdate: {
surfaceId: 'browser',
contents: [
{
key: 'selectedFile',
valueMap: Object.entries(selectedFile).map(([key, value]) => ({
key,
valueString: String(value),
})),
},
],
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ 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">v0.8 文件树、详情卡与手风琴面板嵌套交互</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。加入购物车、修改数量、删除和结算都通过 dataModelUpdate 驱动三张卡片同步,结算后切换到成功 Surface。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
CartCard,
CheckoutSuccessCard,
ColWrapper,
MultiCardContainer,
OrderSummaryCard,
ProductListCard,
SHOP_PRODUCTS,
type CartItem,
type Product,
} from './_shared';
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_8[]>([]);
const cart = ref<CartItem[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const cartUpdate = (): XAgentCommand_v0_8 => ({
dataModelUpdate: {
surfaceId: 'shop',
contents: [{ key: 'cart', valueString: JSON.stringify(cart.value) }],
},
});
const createShopCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'shop',
components: [
{
id: 'product-list-card',
component: {
ProductListCard: {
products: { path: '/products' },
cart: { path: '/cart' },
action: {
name: 'add_to_cart',
context: [{ key: 'product', value: { path: '/addedProduct' } }],
},
},
},
},
{
id: 'cart-card',
component: {
CartCard: {
cart: { path: '/cart' },
updateAction: {
name: 'update_quantity',
context: [
{ key: 'productId', value: { path: '/updateProductId' } },
{ key: 'quantity', value: { path: '/updateQuantity' } },
],
},
removeAction: {
name: 'remove_from_cart',
context: [{ key: 'productId', value: { path: '/removeProductId' } }],
},
},
},
},
{
id: 'order-summary-card',
component: {
OrderSummaryCard: {
cart: { path: '/cart' },
checkoutAction: {
name: 'checkout',
context: [{ key: 'orderData', value: { path: '/orderData' } }],
},
},
},
},
{
id: 'col-products',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'product-list-card' },
},
},
{
id: 'col-cart',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'cart-card' },
},
},
{
id: 'col-summary',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'order-summary-card' },
},
},
{
id: 'root',
component: {
MultiCardContainer: {
children: {
explicitList: ['col-products', 'col-cart', 'col-summary'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'shop',
contents: [
{ key: 'products', valueString: JSON.stringify(SHOP_PRODUCTS) },
{ key: 'cart', valueString: '[]' },
],
},
},
{ beginRendering: { surfaceId: 'shop', root: 'root' } },
];
const createCheckoutSuccess = (total: number, itemCount: number): XAgentCommand_v0_8[] => {
const orderNumber = `ORD-${Date.now().toString(36).toUpperCase()}`;
return [
{
surfaceUpdate: {
surfaceId: 'checkout-success',
components: [
{
id: 'success-card',
component: {
CheckoutSuccessCard: {
total: { literalString: String(total) },
itemCount: { literalString: String(itemCount) },
orderNumber: { literalString: orderNumber },
},
},
},
],
},
},
{ beginRendering: { surfaceId: 'checkout-success', root: 'success-card' } },
];
};
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 }];
append(cartUpdate());
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,
);
append(cartUpdate());
break;
}
case 'remove_from_cart': {
const { productId } = context;
if (!productId) return;
cart.value = cart.value.filter((item) => item.product.id !== productId);
append(cartUpdate());
break;
}
case 'checkout': {
const total = Number(context.total) || 0;
const itemCount = Number(context.itemCount) || 0;
cart.value = [];
append(
{ deleteSurface: { surfaceId: 'shop' } },
...createCheckoutSuccess(total, itemCount),
);
break;
}
case 'restart_demo': {
reload();
break;
}
}
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
cart.value = [];
append(
{ deleteSurface: { surfaceId: 'shop' } },
{ 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">v0.8 JSON 字符串 + dataModelUpdate 同步三张卡片</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>
筛选搜索
搜索词、分类、价格区间和最低评分共同写入 /filters。筛选面板与结果列表读取同一份数据,结果在短暂延迟后刷新。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
ColWrapper,
FilterContainer,
FilterPanel,
FilterProductList,
type FilterState,
} from './_shared';
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_8[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createFilterCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'filter',
components: [
{
id: 'filter-panel',
component: {
FilterPanel: {
selectedCategories: { path: '/filters/categories' },
priceRange: { path: '/filters/priceRange' },
minRating: { path: '/filters/minRating' },
searchKeyword: { path: '/filters/searchKeyword' },
action: {
name: 'update_filters',
context: [{ key: 'filters', value: { path: '/filters' } }],
},
},
},
},
{
id: 'product-list',
component: {
ProductList: { filters: { path: '/filters' } },
},
},
{
id: 'col-filter',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'filter-panel' },
},
},
{
id: 'col-products',
component: {
ColWrapper: { span: { literalString: '16' }, child: 'product-list' },
},
},
{
id: 'root',
component: {
FilterContainer: {
children: { explicitList: ['col-filter', 'col-products'] },
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'filter',
contents: [
{
key: 'filters',
valueMap: [
{ key: 'categories', valueString: '[]' },
{ key: 'priceRange', valueString: '[0,1500]' },
{ key: 'minRating', valueString: '0' },
{ key: 'searchKeyword', valueString: '' },
],
},
],
},
},
{ beginRendering: { surfaceId: 'filter', root: 'root' } },
];
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({
dataModelUpdate: {
surfaceId: 'filter',
contents: [
{
key: 'filters',
valueMap: [
{ key: 'categories', valueString: JSON.stringify(filters.categories || []) },
{ key: 'priceRange', valueString: JSON.stringify(filters.priceRange || [0, 1500]) },
{ key: 'minRating', valueString: String(filters.minRating || 0) },
{ key: 'searchKeyword', valueString: filters.searchKeyword || '' },
],
},
],
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ 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">v0.8 将 filters 以 valueMap JSON 字符串写回</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,
ColWrapper,
}"
:on-action="handleAction"
>
<XCard.Card id="filter" />
</XCard.Box>
</div>
</template>
表单验证
分步注册表单:基本信息 → 安全设置 → 提交结果。字段在本地校验后通过 action.context 的 formData 路径写回,最终 Surface 被结果卡片替换。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import { RegistrationForm, SuccessCard } from './_shared';
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_8[]>([]);
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_8[]) => {
commands.value = [...commands.value, ...next];
};
const createRegistrationCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'registration',
components: [
{
id: 'registration-form',
component: {
RegistrationForm: {
step: { literalString: '0' },
status: { path: '/status' },
errorMessage: { path: '/errorMessage' },
action: {
name: 'submit_step',
context: [{ key: 'formData', value: { path: '/formData' } }],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'registration',
contents: [
{ key: 'status', valueString: '' },
{ key: 'errorMessage', valueString: '' },
],
},
},
{ beginRendering: { surfaceId: 'registration', root: 'registration-form' } },
];
const createResultCommands = (formData: any): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'result',
components: [
{
id: 'success-card',
component: {
SuccessCard: {
username: { literalString: formData?.username ?? '' },
email: { literalString: formData?.email ?? '' },
accountType: { literalString: formData?.accountType ?? 'personal' },
},
},
},
],
},
},
{ beginRendering: { surfaceId: 'result', root: 'success-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(...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({ 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(
{ deleteSurface: { surfaceId: 'registration' } },
{ 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">v0.8 分步校验 + 结果卡片</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 解析
暂无日志,请填写表单后提交
v0.8 的 action.context 是 { key, value: { path } }[]。输入写入 dataModel,空 context 提交时由 Card 解析。
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import { FormCard, FormInput, SubmitButton, Title } from './_shared';
const commands = ref<XAgentCommand_v0_8[]>([]);
const logs = ref<string[]>([]);
const resolvedTip = ref('');
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createFormCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'form',
components: [
{
id: 'title',
component: {
Title: {
text: { literalString: '用户注册表单' },
variant: { literalString: 'h2' },
},
},
},
{
id: 'username-input',
component: {
FormInput: {
label: { literalString: '用户名' },
action: {
name: 'agent:send_context_text',
context: [{ key: 'username', value: { path: '/form/username' } }],
},
},
},
},
{
id: 'email-input',
component: {
FormInput: {
label: { literalString: '邮箱' },
action: {
name: 'agent:send_context_text',
context: [{ key: 'email', value: { path: '/form/email' } }],
},
},
},
},
{
id: 'submit-btn',
component: {
SubmitButton: {
text: { literalString: '提交表单' },
action: {
name: 'agent:send_context_text',
context: [
{ key: 'username', value: { path: '/form/username' } },
{ key: 'email', value: { path: '/form/email' } },
],
},
},
},
},
{
id: 'root',
component: {
FormCard: {
children: {
explicitList: ['title', 'username-input', 'email-input', 'submit-btn'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'form',
contents: [
{
key: 'form',
valueMap: [
{ key: 'username', valueString: '' },
{ key: 'email', valueString: '' },
],
},
],
},
},
{ beginRendering: { surfaceId: 'form', root: 'root' } },
];
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({ 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">
v0.8 数组格式 action.context,由 Card 解析 { path } 引用
</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
XCard.Box / XCard.Card 的用法两边协议共用。下面按 v0.8 命令形状补充类型。模板里请绑定 :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 按版本解析 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 引用格式(v0.8)
v0.8 的 action 配置常用数组描述路径绑定:
{
"id": "submit-btn",
"component": {
"Button": {
"action": {
"name": "agent:send_context_text",
"context": [
{ "key": "username", "value": { "path": "/form/username" } },
{ "key": "email", "value": { "path": "/form/email" } }
]
}
}
}
}解析结果
用户点击按钮后,onAction 收到的 context 会被解析为:
{
"username": { "value": "张三" },
"email": { "value": "test@example.com" }
}注意:
- 仅当 context 中的值本身是
{ path: "xxx" }时才转换 - 组件传入的实际值不会被错误转换
XAgentCommand_v0_8
XAgentCommand_v0_8 是 A2UICommand_v0_8 的别名,支持以下命令。
SurfaceUpdateCommand
更新 Surface 上的组件。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| surfaceUpdate.surfaceId | Surface ID | string | - |
| surfaceUpdate.components | 组件列表 | ComponentWrapper_v0_8[] | - |
ComponentWrapper_v0_8
interface ComponentWrapper_v0_8 {
id: string
component: {
[componentType: string]: {
child?: string
children?: string[] | ExplicitList
[key: string]: any | PathValue | LiteralStringValue
}
}
}ExplicitList
interface ExplicitList {
explicitList: string[]
}DataModelUpdateCommand
更新数据模型(v0.8 格式)。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| dataModelUpdate.surfaceId | Surface ID | string | - |
| dataModelUpdate.contents | 数据内容列表 | Array<{ key: string; valueString?: string; valueMap?: Array<{ key: string; valueString: string }> }> | - |
BeginRenderingCommand
开始渲染。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| beginRendering.surfaceId | Surface ID | string | - |
| beginRendering.root | 根组件 ID | string | - |
DeleteSurfaceCommand
删除 Surface。
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| deleteSurface.surfaceId | Surface ID | string | - |
PathValue
interface PathValue {
path: string
}LiteralStringValue
字面字符串值(v0.8 特有)。
interface LiteralStringValue {
literalString: string
}