From 5ca50df555f08da014d14e3b16239a1cc61249ac Mon Sep 17 00:00:00 2001 From: Ashu <1345258230@qq.com> Date: Wed, 2 Sep 2026 20:48:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Agent=20demo?= =?UTF-8?q?=EF=BC=88drink-skill=20=E7=82=B9=E5=8D=95=20+=20agent-auth-skil?= =?UTF-8?q?l=20=E6=8E=88=E6=9D=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills/drink-skill:WeStoreCafe 点单链路,搜索/详情选规格/下单/追加/ 删改/查看/取消/保存地址,小微 handoff 接力进业务页 - skills/agent-auth-skill:隐私授权卡片、订阅消息、蓝牙授权、 微信运动、收货地址 - packageWeStoreCoffee 分包:home/sku-picker/checkout 三个接力页 + 本地目录数据 - pages/protocol:用户服务协议、隐私政策页 - app.js:注册 wx.onAgentHandoff 监听,新增点单 demo mock 用户 - app.json/page-meta.json:声明 agent.skills 与页面元数据 --- README.md | 15 + miniprogram/app.js | 50 ++- miniprogram/app.json | 33 +- miniprogram/packageWeStoreCoffee/data/seed.js | 116 ++++++ .../pages/checkout/checkout.js | 249 +++++++++++++ .../pages/checkout/checkout.json | 8 + .../pages/checkout/checkout.wxml | 63 ++++ .../pages/checkout/checkout.wxss | 300 +++++++++++++++ .../packageWeStoreCoffee/pages/home/home.js | 136 +++++++ .../packageWeStoreCoffee/pages/home/home.json | 9 + .../packageWeStoreCoffee/pages/home/home.wxml | 56 +++ .../packageWeStoreCoffee/pages/home/home.wxss | 274 ++++++++++++++ .../pages/sku-picker/sku-picker.js | 275 ++++++++++++++ .../pages/sku-picker/sku-picker.json | 8 + .../pages/sku-picker/sku-picker.wxml | 61 +++ .../pages/sku-picker/sku-picker.wxss | 299 +++++++++++++++ miniprogram/page-meta.json | 9 + miniprogram/pages/protocol/privacy.js | 5 + miniprogram/pages/protocol/privacy.json | 3 + miniprogram/pages/protocol/privacy.wxml | 7 + miniprogram/pages/protocol/privacy.wxss | 25 ++ miniprogram/pages/protocol/user-service.js | 5 + miniprogram/pages/protocol/user-service.json | 3 + miniprogram/pages/protocol/user-service.wxml | 7 + miniprogram/pages/protocol/user-service.wxss | 25 ++ miniprogram/skills/agent-auth-skill/SKILL.md | 46 +++ .../apis/authorizeBluetooth.js | 58 +++ .../agent-auth-skill/apis/chooseAddress.js | 63 ++++ .../agent-auth-skill/apis/getWeRunData.js | 71 ++++ .../apis/requestSubscribeMessage.js | 67 ++++ .../apis/requirePrivacyAuthConcurrent.js | 85 +++++ .../requirePrivacyAuthInvalidAgreements.js | 73 ++++ .../apis/requirePrivacyAuthWithAgreements.js | 68 ++++ .../requirePrivacyAuthWithoutAgreements.js | 59 +++ miniprogram/skills/agent-auth-skill/index.js | 21 ++ miniprogram/skills/agent-auth-skill/mcp.json | 176 +++++++++ .../skills/agent-auth-skill/utils/assert.js | 82 +++++ .../utils/privacyAuthorize.js | 47 +++ .../skills/agent-auth-skill/utils/wxCall.js | 38 ++ miniprogram/skills/drink-skill/SKILL.md | 103 ++++++ .../skills/drink-skill/apis/addToOrder.js | 95 +++++ .../skills/drink-skill/apis/cancelOrder.js | 48 +++ .../skills/drink-skill/apis/createOrder.js | 89 +++++ .../drink-skill/apis/removeOrderItem.js | 97 +++++ .../skills/drink-skill/apis/saveAddress.js | 86 +++++ .../skills/drink-skill/apis/searchDrinks.js | 80 ++++ .../skills/drink-skill/apis/selectDrink.js | 73 ++++ .../skills/drink-skill/apis/viewOrder.js | 60 +++ .../components/drink-detail-card/index.js | 66 ++++ .../components/drink-detail-card/index.json | 4 + .../components/drink-detail-card/index.wxml | 18 + .../components/drink-detail-card/index.wxss | 149 ++++++++ .../components/drink-not-found-card/index.js | 21 ++ .../drink-not-found-card/index.json | 4 + .../drink-not-found-card/index.wxml | 6 + .../drink-not-found-card/index.wxss | 59 +++ .../components/order-confirm-card/index.js | 48 +++ .../components/order-confirm-card/index.json | 4 + .../components/order-confirm-card/index.wxml | 46 +++ .../components/order-confirm-card/index.wxss | 249 +++++++++++++ .../components/recommended-drinks/index.js | 47 +++ .../components/recommended-drinks/index.json | 4 + .../components/recommended-drinks/index.wxml | 40 ++ .../components/recommended-drinks/index.wxss | 185 ++++++++++ miniprogram/skills/drink-skill/data/seed.js | 127 +++++++ miniprogram/skills/drink-skill/index.js | 21 ++ miniprogram/skills/drink-skill/mcp.json | 346 ++++++++++++++++++ miniprogram/skills/drink-skill/utils/id.js | 13 + miniprogram/skills/drink-skill/utils/sku.js | 100 +++++ .../skills/drink-skill/utils/storage.js | 114 ++++++ 70 files changed, 5395 insertions(+), 2 deletions(-) create mode 100644 miniprogram/packageWeStoreCoffee/data/seed.js create mode 100644 miniprogram/packageWeStoreCoffee/pages/checkout/checkout.js create mode 100644 miniprogram/packageWeStoreCoffee/pages/checkout/checkout.json create mode 100644 miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxml create mode 100644 miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxss create mode 100644 miniprogram/packageWeStoreCoffee/pages/home/home.js create mode 100644 miniprogram/packageWeStoreCoffee/pages/home/home.json create mode 100644 miniprogram/packageWeStoreCoffee/pages/home/home.wxml create mode 100644 miniprogram/packageWeStoreCoffee/pages/home/home.wxss create mode 100644 miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.js create mode 100644 miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.json create mode 100644 miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxml create mode 100644 miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxss create mode 100644 miniprogram/page-meta.json create mode 100644 miniprogram/pages/protocol/privacy.js create mode 100644 miniprogram/pages/protocol/privacy.json create mode 100644 miniprogram/pages/protocol/privacy.wxml create mode 100644 miniprogram/pages/protocol/privacy.wxss create mode 100644 miniprogram/pages/protocol/user-service.js create mode 100644 miniprogram/pages/protocol/user-service.json create mode 100644 miniprogram/pages/protocol/user-service.wxml create mode 100644 miniprogram/pages/protocol/user-service.wxss create mode 100644 miniprogram/skills/agent-auth-skill/SKILL.md create mode 100644 miniprogram/skills/agent-auth-skill/apis/authorizeBluetooth.js create mode 100644 miniprogram/skills/agent-auth-skill/apis/chooseAddress.js create mode 100644 miniprogram/skills/agent-auth-skill/apis/getWeRunData.js create mode 100644 miniprogram/skills/agent-auth-skill/apis/requestSubscribeMessage.js create mode 100644 miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthConcurrent.js create mode 100644 miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthInvalidAgreements.js create mode 100644 miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithAgreements.js create mode 100644 miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithoutAgreements.js create mode 100644 miniprogram/skills/agent-auth-skill/index.js create mode 100644 miniprogram/skills/agent-auth-skill/mcp.json create mode 100644 miniprogram/skills/agent-auth-skill/utils/assert.js create mode 100644 miniprogram/skills/agent-auth-skill/utils/privacyAuthorize.js create mode 100644 miniprogram/skills/agent-auth-skill/utils/wxCall.js create mode 100644 miniprogram/skills/drink-skill/SKILL.md create mode 100644 miniprogram/skills/drink-skill/apis/addToOrder.js create mode 100644 miniprogram/skills/drink-skill/apis/cancelOrder.js create mode 100644 miniprogram/skills/drink-skill/apis/createOrder.js create mode 100644 miniprogram/skills/drink-skill/apis/removeOrderItem.js create mode 100644 miniprogram/skills/drink-skill/apis/saveAddress.js create mode 100644 miniprogram/skills/drink-skill/apis/searchDrinks.js create mode 100644 miniprogram/skills/drink-skill/apis/selectDrink.js create mode 100644 miniprogram/skills/drink-skill/apis/viewOrder.js create mode 100644 miniprogram/skills/drink-skill/components/drink-detail-card/index.js create mode 100644 miniprogram/skills/drink-skill/components/drink-detail-card/index.json create mode 100644 miniprogram/skills/drink-skill/components/drink-detail-card/index.wxml create mode 100644 miniprogram/skills/drink-skill/components/drink-detail-card/index.wxss create mode 100644 miniprogram/skills/drink-skill/components/drink-not-found-card/index.js create mode 100644 miniprogram/skills/drink-skill/components/drink-not-found-card/index.json create mode 100644 miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxml create mode 100644 miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxss create mode 100644 miniprogram/skills/drink-skill/components/order-confirm-card/index.js create mode 100644 miniprogram/skills/drink-skill/components/order-confirm-card/index.json create mode 100644 miniprogram/skills/drink-skill/components/order-confirm-card/index.wxml create mode 100644 miniprogram/skills/drink-skill/components/order-confirm-card/index.wxss create mode 100644 miniprogram/skills/drink-skill/components/recommended-drinks/index.js create mode 100644 miniprogram/skills/drink-skill/components/recommended-drinks/index.json create mode 100644 miniprogram/skills/drink-skill/components/recommended-drinks/index.wxml create mode 100644 miniprogram/skills/drink-skill/components/recommended-drinks/index.wxss create mode 100644 miniprogram/skills/drink-skill/data/seed.js create mode 100644 miniprogram/skills/drink-skill/index.js create mode 100644 miniprogram/skills/drink-skill/mcp.json create mode 100644 miniprogram/skills/drink-skill/utils/id.js create mode 100644 miniprogram/skills/drink-skill/utils/sku.js create mode 100644 miniprogram/skills/drink-skill/utils/storage.js diff --git a/README.md b/README.md index a780af16..a2e33c79 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ npm install npm run lint ``` +## Agent Demo + +| 路径 | 说明 | +|------|------| +| `miniprogram/skills/drink-skill` | WeStoreCafe 点单:搜索 / 选规格 / 下单 / 地址,小微 handoff 进业务页 | +| `miniprogram/packageWeStoreCoffee` | WeStoreCafe 点单分包:首页 / 选规格 / 结算 + 目录数据 | +| `miniprogram/packageWeStoreCoffee/pages/home/home` | 点单首页(searchDrinks 接力页) | +| `miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker` | 选规格接力页 | +| `miniprogram/packageWeStoreCoffee/pages/checkout/checkout` | 结算接力页 | +| `miniprogram/skills/agent-auth-skill` | 授权卡片 + 订阅消息 / 蓝牙授权 / 微信运动 / 收货地址 | +| `miniprogram/pages/protocol/user-service` | 用户服务协议页 | +| `miniprogram/pages/protocol/privacy` | 隐私政策页 | + +协议名称与详情页由原子接口调用 `wx.requestAgentPrivacyAuthorization({ privacyAgreements })` 时传入,不再在 `app.json` 中静态声明。接口不再接收 `type`,统一使用 default 文案模板。 + ## 截图 \ No newline at end of file diff --git a/miniprogram/app.js b/miniprogram/app.js index 3262bf86..b6f26a96 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -65,6 +65,8 @@ App({ }).catch(({ mod, errMsg }) => { console.error(`installRouteBuilder path: ${mod}, ${errMsg}`) }) + this.registerAgentHandoff() + this.ensureCafeDemoUser() }, onShow(opts) { @@ -97,7 +99,53 @@ App({ hasLogin: false, openid: null, iconTabbar: '/page/extend/images/icon_tabbar.png', - systemInfo: {} + systemInfo: {}, + agentHandoffs: {} + }, + + registerAgentHandoff() { + if (!wx.onAgentHandoff) { + console.warn('[App] 当前基础库不支持 wx.onAgentHandoff,请使用支持小微 handoff 的版本') + return + } + wx.onAgentHandoff(({ pageId, path, query, payload }) => { + console.log('[App] onAgentHandoff', { pageId, path, query, payload }) + this.globalData.agentHandoffs = this.globalData.agentHandoffs || {} + this.globalData.agentHandoffs[pageId] = { path, query, payload } + }) + }, + + takeAgentHandoff(pageId) { + const map = this.globalData.agentHandoffs || {} + const handoff = map[pageId] + if (handoff) delete map[pageId] + return handoff || null + }, + + ensureCafeDemoUser() { + const userInfo = wx.getStorageSync('userInfo') + if (userInfo && userInfo.openid) { + this.globalData.userInfo = userInfo + return + } + const mockOpenid = `mock_${Date.now()}` + const next = { + openid: mockOpenid, + unionid: `union_${mockOpenid}`, + nickname: `用户${Math.floor(Math.random() * 10000)}`, + avatarUrl: '', + loginTime: new Date().toISOString() + } + wx.setStorageSync('userInfo', next) + this.globalData.userInfo = next + const initialized = wx.getStorageSync(`initialized_${mockOpenid}`) + if (!initialized) { + wx.setStorageSync(`address_${mockOpenid}`, null) + wx.setStorageSync(`orders_${mockOpenid}`, []) + wx.setStorageSync(`pending_order_${mockOpenid}`, null) + wx.setStorageSync(`active_order_${mockOpenid}`, null) + wx.setStorageSync(`initialized_${mockOpenid}`, true) + } }, // lazy loading openid getUserOpenId(callback) { diff --git a/miniprogram/app.json b/miniprogram/app.json index ba3719ae..70535fd2 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -14,7 +14,9 @@ "page/ad/optimize-ad/home-small-card/home-small-card", "page/ad/optimize-ad/home-large-card/home-large-card", "page/ad/optimize-ad/other-small-card/other-small-card", - "page/ad/optimize-ad/other-large-card/other-large-card" + "page/ad/optimize-ad/other-large-card/other-large-card", + "pages/protocol/user-service", + "pages/protocol/privacy" ], "usingComponents": { "mp-navigation-bar": "/component/navigation-bar/navigation-bar" @@ -419,6 +421,20 @@ "pages/customParticle/scene-customParticle-shapeEmitter/index", "pages/customParticle/scene-customParticle-meshEmitter/index" ] + }, + { + "root": "skills", + "pages": [], + "independent": true + }, + { + "root": "packageWeStoreCoffee", + "name": "westoreCoffee", + "pages": [ + "pages/home/home", + "pages/sku-picker/sku-picker", + "pages/checkout/checkout" + ] } ], "preloadRule": { @@ -539,6 +555,21 @@ "wxfdcee92a299bcaf1" ], "lazyCodeLoading": "requiredComponents", + "agent": { + "skills": [ + { + "name": "drink", + "description": "WeStoreCafe 点单场景:查询推荐饮品、选择规格、填写收货地址、下单支付", + "path": "skills/drink-skill" + }, + { + "name": "agent-auth", + "description": "Agent 授权与开放能力:隐私授权卡片、订阅消息、蓝牙授权、微信运动、收货地址 chooseAddress。", + "path": "skills/agent-auth-skill" + } + ], + "pageMetadata": "page-meta.json" + }, "__lazyCodeLoadingChunk": true, "rendererOptions": { "skyline": { diff --git a/miniprogram/packageWeStoreCoffee/data/seed.js b/miniprogram/packageWeStoreCoffee/data/seed.js new file mode 100644 index 00000000..70b74aa6 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/data/seed.js @@ -0,0 +1,116 @@ +const CATEGORIES = [ + { id: 141, name: '黑咖/果咖' }, + { id: 133, name: '奶咖' }, + { id: 136, name: '蔬果汁' }, + { id: 135, name: '水果茶' }, + { id: 139, name: '奶茶/暖饮' } +] + +const DRINKS = [ + { id: 289, name: '美式', price: 15, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + { id: 293, name: '白桃茶美式', price: 16, categoryId: 141, categoryName: '黑咖/果咖', description: '清爽茶香', imageUrl: '' }, + { id: 295, name: '杨桃油柑美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '鲜油柑+杨桃', imageUrl: '' }, + { id: 353, name: '凤梨美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '推荐', imageUrl: '' }, + { id: 362, name: '葡萄美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + { id: 294, name: '爆柠美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + { id: 296, name: '鲜橙美式', price: 20, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + + { id: 393, name: '焦糖蜂窝拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 395, name: '小黄油拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 368, name: '黄油Dirty', price: 20, categoryId: 133, categoryName: '奶咖', description: '到店制作 大口喝', imageUrl: '' }, + { id: 390, name: '轻芝士拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '法国kiri芝士', imageUrl: '' }, + { id: 195, name: '拿铁', price: 18, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 384, name: 'SOE澳白', price: 18, categoryId: 133, categoryName: '奶咖', description: '小热杯 更"咖啡"!', imageUrl: '' }, + { id: 383, name: '秋玫瑰拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '玫瑰与奶咖的双向奔赴', imageUrl: '' }, + { id: 299, name: '生椰拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 297, name: '橘皮拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 208, name: '燕麦拿铁', price: 22, categoryId: 133, categoryName: '奶咖', description: 'OATLY燕麦奶', imageUrl: '' }, + + { id: 394, name: '杨桃+油柑', price: 18, categoryId: 136, categoryName: '蔬果汁', description: '', imageUrl: '' }, + { id: 389, name: '胡萝卜汁', price: 13, categoryId: 136, categoryName: '蔬果汁', description: '补充维生素A', imageUrl: '' }, + { id: 386, name: '青瓜+西芹+苹果', price: 15, categoryId: 136, categoryName: '蔬果汁', description: '全能果蔬饮就它了', imageUrl: '' }, + { id: 387, name: '青瓜+雪梨+柠檬', price: 15, categoryId: 136, categoryName: '蔬果汁', description: '一口喝出清润好气色', imageUrl: '' }, + { id: 313, name: '苹果胡萝卜汁', price: 17, categoryId: 136, categoryName: '蔬果汁', description: '现榨无糖0负担', imageUrl: '' }, + + { id: 315, name: '蜂蜜柚子茶', price: 12, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 300, name: '海盐柠檬', price: 13, categoryId: 135, categoryName: '水果茶', description: '补充电解质', imageUrl: '' }, + { id: 302, name: '鸭屎香柠檬茶', price: 13, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 301, name: '白桃乌龙柠檬茶', price: 13, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 305, name: '满杯百香果', price: 13, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 306, name: '百香益力多', price: 16, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 307, name: '柠檬益力多', price: 16, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 364, name: '黑加仑柠檬茶', price: 16, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 303, name: '杨桃油柑柠檬茶', price: 16, categoryId: 135, categoryName: '水果茶', description: '鲜油柑榨+杨桃', imageUrl: '' }, + + { id: 371, name: '抹茶脑袋', price: 17, categoryId: 139, categoryName: '奶茶/暖饮', description: '冷热皆宜', imageUrl: '' }, + { id: 372, name: '茶萃奶白', price: 16, categoryId: 139, categoryName: '奶茶/暖饮', description: '冷热皆宜', imageUrl: '' }, + { id: 316, name: '黑糖姜母茶', price: 12, categoryId: 139, categoryName: '奶茶/暖饮', description: '热饮 红枣配枸杞', imageUrl: '' }, + { id: 381, name: '红糖姜桃胶撞奶', price: 18, categoryId: 139, categoryName: '奶茶/暖饮', description: '热饮 姜跟牛奶很般配', imageUrl: '' } +] + +function buildSkuSchema(categoryId) { + const tempDim = { + key: 'temperature', label: '温度', multiple: false, + options: [ + { value: 'ice', label: '冰' }, + { value: 'hot', label: '热' } + ] + } + const sugarDim = { + key: 'sugar', label: '糖度', multiple: false, + options: [ + { value: 'none', label: '无糖' }, + { value: 'less', label: '少糖' }, + { value: 'normal', label: '标准' }, + { value: 'more', label: '多糖' } + ] + } + const cupDim = { + key: 'cupSize', label: '杯型', multiple: false, + options: [ + { value: 'medium', label: '中杯', extraPrice: 0 }, + { value: 'large', label: '大杯', extraPrice: 3 }, + { value: 'xlarge', label: '超大杯', extraPrice: 6 } + ] + } + const milkToppings = { + key: 'toppings', label: '加料', multiple: true, + options: [ + { value: 'oatMilk', label: '燕麦奶', extraPrice: 3 }, + { value: 'coconut', label: '椰浆', extraPrice: 3 }, + { value: 'extraShot', label: '额外浓缩', extraPrice: 5 } + ] + } + const teaToppings = { + key: 'toppings', label: '加料', multiple: true, + options: [ + { value: 'pearl', label: '珍珠', extraPrice: 2 }, + { value: 'jelly', label: '椰果', extraPrice: 2 } + ] + } + const juiceCupDim = { + key: 'cupSize', label: '杯型', multiple: false, + options: [ + { value: 'medium', label: '中杯', extraPrice: 0 }, + { value: 'large', label: '大杯', extraPrice: 3 } + ] + } + + switch (categoryId) { + case 141: return { dimensions: [tempDim, sugarDim] } + case 133: return { dimensions: [tempDim, sugarDim, cupDim, milkToppings] } + case 136: return { dimensions: [tempDim, juiceCupDim] } + case 135: return { dimensions: [tempDim, sugarDim, cupDim] } + case 139: return { dimensions: [tempDim, sugarDim, cupDim, teaToppings] } + default: return { dimensions: [tempDim, sugarDim] } + } +} + +function buildCatalog() { + return DRINKS.map(d => ({ + ...d, + skuSchema: buildSkuSchema(d.categoryId) + })) +} + +module.exports = { CATEGORIES, DRINKS, buildCatalog, buildSkuSchema } diff --git a/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.js b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.js new file mode 100644 index 00000000..be809f58 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.js @@ -0,0 +1,249 @@ +function calcHeaderPaddingTop() { + try { + const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() + const statusBar = (info && info.statusBarHeight) || 20 + let capsuleBottom = statusBar + 44 + if (wx.getMenuButtonBoundingClientRect) { + const rect = wx.getMenuButtonBoundingClientRect() + if (rect && rect.bottom) capsuleBottom = rect.bottom + 8 + } + const winWidth = (info && info.windowWidth) || 375 + return Math.max(Math.round(capsuleBottom * 750 / winWidth), 64) + } catch (e) { + return 88 + } +} + +function calcNavBack() { + try { + const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() + const winWidth = (info && info.windowWidth) || 375 + const px2rpx = (px) => Math.round(px * 750 / winWidth) + let top = ((info && info.statusBarHeight) || 20) + 6 + let size = 32 + if (wx.getMenuButtonBoundingClientRect) { + const rect = wx.getMenuButtonBoundingClientRect() + if (rect && rect.top) { + top = rect.top + size = rect.height || 32 + } + } + return { top: px2rpx(top), size: px2rpx(size) } + } catch (e) { + return { top: 88, size: 64 } + } +} + +function getOpenid() { + const userInfo = wx.getStorageSync('userInfo') + return (userInfo && userInfo.openid) ? userInfo.openid : 'anonymous' +} + +function loadOrder(orderId) { + if (!orderId) return null + try { + const orders = wx.getStorageSync(`orders_${getOpenid()}`) || [] + return orders.find(o => o.orderId === orderId) || null + } catch (e) { + return null + } +} + +function saveOrder(order) { + try { + const key = `orders_${getOpenid()}` + const orders = wx.getStorageSync(key) || [] + const idx = orders.findIndex(o => o.orderId === order.orderId) + if (idx >= 0) orders[idx] = order + else orders.push(order) + wx.setStorageSync(key, orders) + } catch (e) {} +} + +function normalizeOrder(order) { + if (!order) return order + if (!Array.isArray(order.items)) { + if (order.drinkName || order.drinkId) { + order.items = [{ + drinkId: order.drinkId, + drinkName: order.drinkName, + imageUrl: order.imageUrl, + specs: order.specs, + specText: order.specText, + basePrice: order.basePrice, + extraPrice: order.extraPrice, + totalPrice: order.totalPrice + }] + } else { + order.items = [] + } + } + order.itemCount = order.items.length + order.totalPrice = order.items.reduce((sum, it) => sum + (Number(it.totalPrice) || 0), 0) + return order +} + +function clearActiveOrderId() { + try { wx.removeStorageSync(`active_order_${getOpenid()}`) } catch (e) {} +} + +function buildDisplayItems(order) { + const items = (order && Array.isArray(order.items)) ? order.items : [] + const map = {} + const list = [] + items.forEach(it => { + const key = `${it.drinkName}||${it.specText || ''}` + if (map[key]) { + map[key].qty += 1 + map[key].lineTotal += Number(it.totalPrice) || 0 + } else { + const row = { + drinkName: it.drinkName, + specText: it.specText, + imageUrl: it.imageUrl, + unitPrice: Number(it.totalPrice) || 0, + qty: 1, + lineTotal: Number(it.totalPrice) || 0 + } + map[key] = row + list.push(row) + } + }) + return list +} + +Page({ + data: { + order: null, + displayItems: [], + address: null, + paying: false, + paid: false, + headerPaddingTop: 88, + navBackTop: 88, + navBackSize: 64 + }, + + onLoad(query) { + const nb = calcNavBack() + this.setData({ headerPaddingTop: calcHeaderPaddingTop(), navBackTop: nb.top, navBackSize: nb.size }) + + const orderId = query && query.orderId ? String(query.orderId) : '' + + let payload = null + try { + const app = getApp() + if (app && app.takeAgentHandoff) { + const handoff = app.takeAgentHandoff(this.getPageId()) + payload = handoff && handoff.payload + if (handoff) console.log('[checkout] handoff', handoff) + } + } catch (e) {} + + let order = loadOrder(orderId) + if (payload && payload.orderId) { + order = Object.assign({}, order || {}, payload) + saveOrder(order) + } + + if (!order || !order.orderId) { + wx.showToast({ title: '订单不存在', icon: 'none' }) + return + } + + normalizeOrder(order) + + const address = order.address || this._readStoredAddress() + if (address && !order.address) { + order.address = address + order.status = 'confirmed' + saveOrder(order) + } + + this.setData({ + order, + displayItems: buildDisplayItems(order), + address: address || null, + paid: order.status === 'paid' + }) + }, + + onTapBack() { + const pages = getCurrentPages() + if (pages && pages.length > 1) { + wx.navigateBack({ delta: 1 }) + } else { + wx.reLaunch({ url: '/packageWeStoreCoffee/pages/home/home' }) + } + }, + + _readStoredAddress() { + try { + return wx.getStorageSync(`address_${getOpenid()}`) || null + } catch (e) { + return null + } + }, + + onTapChooseAddress() { + if (!wx.chooseAddress) { + wx.showToast({ title: '当前环境不支持选择地址', icon: 'none' }) + return + } + wx.chooseAddress({ + success: (res) => { + const detail = (res.provinceName || '') + (res.cityName || '') + (res.countyName || '') + (res.detailInfo || '') + const address = { name: res.userName, phone: res.telNumber, detail } + try { wx.setStorageSync(`address_${getOpenid()}`, address) } catch (e) {} + const order = Object.assign({}, this.data.order, { address, status: 'confirmed' }) + saveOrder(order) + this.setData({ address, order }) + }, + fail: () => { + wx.showToast({ title: '未选择地址', icon: 'none' }) + } + }) + }, + + onTapPay() { + const { order, address, paying } = this.data + if (paying) return + if (!order) return + if (!address) { + wx.showToast({ title: '请先选择收货地址', icon: 'none' }) + return + } + this.setData({ paying: true }) + + const finishPaid = (method) => { + const paid = Object.assign({}, order, { + status: 'paid', + payTime: new Date().toISOString(), + payMethod: method + }) + saveOrder(paid) + clearActiveOrderId() + this.setData({ paying: false, paid: true, order: paid }) + wx.showToast({ title: '支付成功', icon: 'success' }) + } + + if (!wx.requestPayment) { + finishPaid('mock') + return + } + // 注意:以下为演示用占位参数 + wx.requestPayment({ + timeStamp: String(Math.floor(Date.now() / 1000)), + nonceStr: 'demo_' + Math.random().toString(36).slice(2, 10), + package: 'prepay_id=demo_prepay', + signType: 'RSA', + paySign: 'demo_sign', + success: () => finishPaid('wxpay'), + fail: () => finishPaid('mock') + }) + }, + + onTapBackHome() { + wx.reLaunch({ url: '/packageWeStoreCoffee/pages/home/home' }) + } +}) diff --git a/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.json b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.json new file mode 100644 index 00000000..337ef509 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.json @@ -0,0 +1,8 @@ +{ + "navigationStyle": "custom", + "navigationBarTitleText": "订单结算", + "darkmode": true, + "usingComponents": {}, + "componentFramework": "glass-easel", + "renderer": "skyline" +} diff --git a/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxml b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxml new file mode 100644 index 00000000..43698c63 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxml @@ -0,0 +1,63 @@ + + + + + + 订单结算 + 从小微对话接力进入 · 在小程序内完成下单 + + + + + 收货地址 + + + {{address.name}} + {{address.phone}} + + {{address.detail}} + + + + 选择收货地址 + + + + + 商品信息 · 共{{order.itemCount}}件 + + + + + {{goods.drinkName}} + x{{goods.qty}} + + {{goods.specText}} + + ¥{{goods.lineTotal}} + + + + 合计¥{{order.totalPrice}} + + + + + 支付成功,预计 20 分钟内出杯 + + + + + + + 实付 + ¥{{order.totalPrice}} + + + {{paying ? '支付中...' : '立即支付'}} + + + + 完成 · 返回首页 + + + diff --git a/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxss b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxss new file mode 100644 index 00000000..4e41dec3 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/checkout/checkout.wxss @@ -0,0 +1,300 @@ +page { + background-color: #FFF8F0; + height: 100%; + overflow-x: hidden; +} + +.ck-page { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow-x: hidden; + background: linear-gradient(180deg, #FFF8F0 0%, #F5EFE6 100%); +} + +.nav-back { + position: fixed; + left: 24rpx; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 2rpx 10rpx rgba(92, 58, 33, 0.12); +} + +.nav-back-arrow { + width: 16rpx; + height: 16rpx; + border-left: 6rpx solid #2A1810; + border-bottom: 6rpx solid #2A1810; + transform: translateX(3rpx) rotate(45deg); +} + +.nav-back-hover { opacity: 0.7; } + +@media (prefers-color-scheme: dark) { + .nav-back { + background: rgba(30, 30, 30, 0.92); + box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.3); + } + .nav-back-arrow { + border-left-color: #E8E0D8; + border-bottom-color: #E8E0D8; + } +} + +.ck-header { + padding: 32rpx; + background: #FFFFFF; + border-bottom: 2rpx solid #EDE3D4; + flex-shrink: 0; +} + +.ck-title { + font-size: 38rpx; + font-weight: 800; + color: #2A1810; +} + +.ck-sub { + font-size: 22rpx; + color: #A89180; + margin-top: 8rpx; +} + +.ck-body { + flex: 1; + padding: 24rpx 32rpx 0; + box-sizing: border-box; +} + +.ck-card { + background: #FFFFFF; + border-radius: 20rpx; + padding: 28rpx; + margin-bottom: 24rpx; + border: 2rpx solid #EDE3D4; +} + +.ck-card-title { + font-size: 26rpx; + font-weight: 700; + color: #6B5544; + margin-bottom: 20rpx; +} + +.ck-addr-line { + display: flex; + align-items: baseline; +} + +.ck-addr-name { + font-size: 30rpx; + font-weight: 700; + color: #2A1810; +} + +.ck-addr-phone { + font-size: 26rpx; + color: #6B5544; + margin-left: 20rpx; +} + +.ck-addr-detail { + font-size: 26rpx; + color: #6B5544; + margin-top: 10rpx; + line-height: 1.5; +} + +.ck-addr-empty { + padding: 20rpx 0; +} + +.ck-addr-empty-text { + font-size: 28rpx; + color: #8B5E3C; + font-weight: 600; +} + +.row-hover { opacity: 0.7; } + +.ck-goods { + display: flex; + align-items: center; +} + +.ck-goods + .ck-goods { + margin-top: 24rpx; + padding-top: 24rpx; + border-top: 2rpx solid #F2EADF; +} + +.ck-thumb { + width: 120rpx; + height: 120rpx; + border-radius: 16rpx; + background-color: #F5EFE6; + flex-shrink: 0; +} + +.ck-goods-info { + flex: 1; + margin-left: 20rpx; + min-width: 0; +} + +.ck-goods-name { + display: flex; + align-items: baseline; + font-size: 30rpx; + font-weight: 700; + color: #2A1810; +} + +.ck-goods-qty { + margin-left: 12rpx; + font-size: 24rpx; + font-weight: 700; + color: #8B5E3C; + flex-shrink: 0; +} + +.ck-goods-spec { + font-size: 24rpx; + color: #6B5544; + margin-top: 8rpx; +} + +.ck-goods-price { + font-size: 32rpx; + font-weight: 800; + color: #5C3A21; + margin-left: 16rpx; +} + +.ck-fee { + margin-top: 24rpx; + border-top: 2rpx dashed #EDE3D4; + padding-top: 20rpx; +} + +.ck-fee-row { + display: flex; + justify-content: space-between; + font-size: 26rpx; + color: #6B5544; + margin-bottom: 12rpx; +} + +.ck-fee-total { + margin-top: 4rpx; + margin-bottom: 0; + font-weight: 700; + color: #2A1810; +} + +.ck-fee-total-num { + font-size: 32rpx; + font-weight: 800; + color: #5C3A21; +} + +.ck-paid-tip { + text-align: center; + font-size: 26rpx; + color: #8B5E3C; + margin: 8rpx 0 16rpx; +} + +.ck-bottom-spacer { + height: 40rpx; +} + +.ck-footer { + background: #FFFFFF; + border-top: 2rpx solid #EDE3D4; + padding: 20rpx 32rpx; + flex-shrink: 0; +} + +.safe-area-bottom { + padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx); +} + +.ck-pay-line { + display: flex; + align-items: center; + gap: 20rpx; +} + +.ck-pay-amount { + flex: 1; + display: flex; + align-items: baseline; +} + +.ck-pay-label { + font-size: 24rpx; + color: #6B5544; + margin-right: 8rpx; +} + +.ck-pay-num { + font-size: 44rpx; + font-weight: 800; + color: #5C3A21; +} + +.ck-pay-btn { + padding: 24rpx 56rpx; + border-radius: 999rpx; + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + flex-shrink: 0; +} + +.ck-pay-done { + text-align: center; +} + +.ck-pay-btn-disabled { + opacity: 0.5; +} + +.btn-hover { opacity: 0.85; } + +.ck-pay-btn-text { + color: #FFFFFF; + font-size: 30rpx; + font-weight: 700; + letter-spacing: 2rpx; +} + +@media (prefers-color-scheme: dark) { + page { background-color: #121212; } + .ck-page { background: linear-gradient(180deg, #121212 0%, #1A1510 100%); } + .ck-header { background: #1E1E1E; border-bottom-color: #3E3228; } + .ck-title { color: #E8E0D8; } + .ck-sub { color: #7A6E62; } + .ck-card { background: #1E1E1E; border-color: #3E3228; } + .ck-card-title { color: #9E8E7E; } + .ck-addr-name { color: #E8E0D8; } + .ck-addr-phone, .ck-addr-detail { color: #9E8E7E; } + .ck-addr-empty-text { color: #D4A574; } + .ck-thumb { background-color: #2A2A2A; } + .ck-goods + .ck-goods { border-top-color: #3E3228; } + .ck-goods-name { color: #E8E0D8; } + .ck-goods-qty { color: #D4A574; } + .ck-goods-spec { color: #9E8E7E; } + .ck-goods-price, .ck-fee-total-num, .ck-pay-num { color: #D4A574; } + .ck-fee { border-top-color: #3E3228; } + .ck-fee-row { color: #9E8E7E; } + .ck-fee-total { color: #E8E0D8; } + .ck-paid-tip { color: #D4A574; } + .ck-footer { background: #1E1E1E; border-top-color: #3E3228; } + .ck-pay-label { color: #9E8E7E; } + .ck-pay-btn { background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); } +} diff --git a/miniprogram/packageWeStoreCoffee/pages/home/home.js b/miniprogram/packageWeStoreCoffee/pages/home/home.js new file mode 100644 index 00000000..a836cbd5 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/home/home.js @@ -0,0 +1,136 @@ +const { buildCatalog, CATEGORIES } = require('../../data/seed.js') + +const CATALOG_VERSION = 3 + +function loadCatalog() { + const cached = wx.getStorageSync('drinks_catalog') + const version = wx.getStorageSync('drinks_catalog_version') + if (cached && cached.length && version === CATALOG_VERSION) return cached + const fresh = buildCatalog() + try { + wx.setStorageSync('drinks_catalog', fresh) + wx.setStorageSync('drinks_catalog_version', CATALOG_VERSION) + } catch (e) {} + return fresh +} + +function calcHeaderPaddingTop() { + try { + const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() + const statusBar = (info && info.statusBarHeight) || 20 + let capsuleBottom = statusBar + 44 + if (wx.getMenuButtonBoundingClientRect) { + const rect = wx.getMenuButtonBoundingClientRect() + if (rect && rect.bottom) capsuleBottom = rect.bottom + 8 + } + const winWidth = (info && info.windowWidth) || 375 + return Math.max(Math.round(capsuleBottom * 750 / winWidth), 64) + } catch (e) { + return 88 + } +} + +Page({ + data: { + categories: [], + activeCategoryId: 0, + items: [], + title: '全部饮品', + subTitle: '', + searchKeyword: '', + isSearching: false, + headerPaddingTop: 88 + }, + + onLoad(query) { + this.setData({ headerPaddingTop: calcHeaderPaddingTop() }) + + const keyword = query && query.keyword ? String(query.keyword) : '' + + try { + const app = getApp() + if (app && app.takeAgentHandoff) { + const handoff = app.takeAgentHandoff(this.getPageId()) + if (handoff) console.log('[home] handoff', handoff) + } + } catch (e) {} + + const catalog = loadCatalog() + this._catalog = catalog + const categories = [{ id: 0, name: '全部' }].concat(CATEGORIES) + + let items = catalog + let title = '全部饮品' + let subTitle = `共 ${catalog.length} 款` + let searchKeyword = '' + let isSearching = false + + if (keyword) { + const kw = keyword.toLowerCase() + items = catalog.filter(d => + d.name.toLowerCase().includes(kw) || + d.categoryName.toLowerCase().includes(kw) || + (d.description || '').toLowerCase().includes(kw) + ) + title = `「${keyword}」搜索结果` + subTitle = `共 ${items.length} 款` + searchKeyword = keyword + isSearching = true + } + + this.setData({ categories, activeCategoryId: 0, items, title, subTitle, searchKeyword, isSearching }) + }, + + onSearchInput(e) { + this.setData({ searchKeyword: e.detail.value }) + }, + + onSearchConfirm() { + this.runSearch() + }, + + runSearch() { + const kw = (this.data.searchKeyword || '').trim() + if (!kw) { + this.onClearSearch() + return + } + const lower = kw.toLowerCase() + const items = this._catalog.filter(d => + d.name.toLowerCase().includes(lower) || + d.categoryName.toLowerCase().includes(lower) || + (d.description || '').toLowerCase().includes(lower) + ) + this.setData({ + isSearching: true, + items, + title: `「${kw}」搜索结果`, + subTitle: `共 ${items.length} 款` + }) + }, + + onClearSearch() { + this.setData({ + searchKeyword: '', + isSearching: false, + activeCategoryId: 0, + items: this._catalog, + title: '全部饮品', + subTitle: `共 ${this._catalog.length} 款` + }) + }, + + onTapCategory(e) { + const id = Number(e.currentTarget.dataset.id) + const items = id === 0 + ? this._catalog + : this._catalog.filter(d => d.categoryId === id) + this.setData({ activeCategoryId: id, items, subTitle: `共 ${items.length} 款` }) + }, + + onTapDrink(e) { + const item = e.currentTarget.dataset.item + if (!item) return + wx.navigateTo({ url: `/packageWeStoreCoffee/pages/sku-picker/sku-picker?drinkId=${item.id}` }) + } +}) diff --git a/miniprogram/packageWeStoreCoffee/pages/home/home.json b/miniprogram/packageWeStoreCoffee/pages/home/home.json new file mode 100644 index 00000000..f3288445 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/home/home.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "WeStoreCafe 点单场景", + "navigationBarBackgroundColor": "#5C3A21", + "navigationBarTextStyle": "white", + "navigationStyle": "custom", + "backgroundColor": "#FFF8F0", + "darkmode": true, + "usingComponents": {} +} diff --git a/miniprogram/packageWeStoreCoffee/pages/home/home.wxml b/miniprogram/packageWeStoreCoffee/pages/home/home.wxml new file mode 100644 index 00000000..846cb293 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/home/home.wxml @@ -0,0 +1,56 @@ + + + {{title}} + {{subTitle}} + + + + + 🔍 + + ✕ + + + + + {{item.name}} + + + + + + + {{item.name}} + {{item.description}} + + ¥{{item.price}} + {{item.categoryName}} + + + + 没有找到相关饮品~ + + + diff --git a/miniprogram/packageWeStoreCoffee/pages/home/home.wxss b/miniprogram/packageWeStoreCoffee/pages/home/home.wxss new file mode 100644 index 00000000..d5135cb6 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/home/home.wxss @@ -0,0 +1,274 @@ +page { + background-color: #FFF8F0; + height: 100%; + overflow-x: hidden; +} + +.md-page { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow-x: hidden; + background: linear-gradient(180deg, #FFF8F0 0%, #F5EFE6 100%); +} + +.md-header { + padding: 32rpx 32rpx 12rpx; + position: relative; +} + +.md-ai-entry { + position: absolute; + right: 32rpx; + bottom: 12rpx; + padding: 8rpx 20rpx; + background: #5C3A21; + color: #FFF; + font-size: 22rpx; + border-radius: 999rpx; + letter-spacing: 0.5rpx; + box-shadow: 0 2rpx 6rpx rgba(92, 58, 33, 0.25); +} +.md-ai-entry-hover { opacity: 0.75; } + +.md-title { + font-size: 40rpx; + font-weight: 700; + color: #2A1810; + letter-spacing: 1rpx; +} + +.md-sub { + font-size: 22rpx; + color: #A89180; + margin-top: 4rpx; +} + +.md-search { + padding: 8rpx 24rpx 4rpx; + flex-shrink: 0; +} + +.md-search-box { + display: flex; + align-items: center; + height: 72rpx; + padding: 0 24rpx; + background: #FFFFFF; + border-radius: 999rpx; + box-shadow: 0 2rpx 8rpx rgba(92, 58, 33, 0.05); +} + +.md-search-icon { + font-size: 28rpx; + margin-right: 12rpx; + opacity: 0.6; +} + +.md-search-input { + flex: 1; + font-size: 28rpx; + color: #2A1810; + height: 100%; +} + +.md-search-ph { + color: #B8A898; +} + +.md-search-clear { + width: 40rpx; + height: 40rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #EFE7DC; + color: #8B7A68; + font-size: 24rpx; + margin-left: 8rpx; +} + +.clear-hover { opacity: 0.7; } + +.md-tabs { + white-space: nowrap; + padding: 12rpx 24rpx; + flex-shrink: 0; +} + +.md-tab { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14rpx 28rpx; + margin-right: 16rpx; + background: #FFFFFF; + border-radius: 999rpx; + font-size: 26rpx; + color: #6B5544; + box-shadow: 0 2rpx 8rpx rgba(92, 58, 33, 0.05); +} + +.md-tab-active { + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + color: #FFFFFF; + font-weight: 600; +} + +.tab-hover { opacity: 0.85; } + +.md-list { + flex: 1; + padding: 8rpx 24rpx 0; + box-sizing: border-box; +} + +.md-grid { + display: flex; + flex-wrap: wrap; + gap: 20rpx; + padding-top: 12rpx; +} + +.md-item { + width: calc((100% - 20rpx) / 2); + background: #FFFFFF; + border-radius: 24rpx; + padding: 16rpx; + box-sizing: border-box; + box-shadow: 0 4rpx 16rpx rgba(92, 58, 33, 0.06); + display: flex; + flex-direction: column; +} + +.item-hover { + opacity: 0.85; + transform: scale(0.98); +} + +.md-img { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 16rpx; + background-color: #F5EFE6; + margin-bottom: 12rpx; +} + +.md-name { + font-size: 28rpx; + font-weight: 600; + color: #2A1810; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.md-desc { + font-size: 22rpx; + color: #6B5544; + margin-top: 4rpx; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.md-foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12rpx; +} + +.md-price { + font-size: 32rpx; + font-weight: 700; + color: #5C3A21; +} + +.md-cat-tag { + font-size: 20rpx; + color: #8B5E3C; + background: #FFF8F0; + padding: 4rpx 12rpx; + border-radius: 8rpx; +} + +.md-empty { + text-align: center; + font-size: 26rpx; + color: #A89180; + padding: 80rpx 0; +} + +.md-bottom { + height: 40rpx; +} + +.safe-area-bottom { + padding-bottom: calc(env(safe-area-inset-bottom) + 16rpx); +} + +@media (prefers-color-scheme: dark) { + page { + background-color: #121212; + } + .md-page { + background: linear-gradient(180deg, #121212 0%, #1A1510 100%); + } + .md-title { + color: #E8E0D8; + } + .md-sub { + color: #7A6E62; + } + .md-search-box { + background: #1E1E1E; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.2); + } + .md-search-input { + color: #E8E0D8; + } + .md-search-ph { + color: #6E6258; + } + .md-search-clear { + background: #2E2A24; + color: #9E8E7E; + } + .md-tab { + background: #1E1E1E; + color: #9E8E7E; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.2); + } + .md-tab-active { + background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); + color: #FFFFFF; + } + .md-item { + background: #1E1E1E; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.2); + } + .md-img { + background-color: #2A2A2A; + } + .md-name { + color: #E8E0D8; + } + .md-desc { + color: #9E8E7E; + } + .md-price { + color: #D4A574; + } + .md-cat-tag { + color: #D4A574; + background: #2A2218; + } + .md-empty { + color: #7A6E62; + } +} diff --git a/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.js b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.js new file mode 100644 index 00000000..919d6146 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.js @@ -0,0 +1,275 @@ +const { buildCatalog } = require('../../data/seed.js') + +const CATALOG_VERSION = 3 + +const DEFAULT_SINGLE_SPEC = { temperature: 'ice', sugar: 'normal', cupSize: 'medium' } + +function loadCatalog() { + const cached = wx.getStorageSync('drinks_catalog') + const version = wx.getStorageSync('drinks_catalog_version') + if (cached && cached.length && version === CATALOG_VERSION) return cached + const fresh = buildCatalog() + try { + wx.setStorageSync('drinks_catalog', fresh) + wx.setStorageSync('drinks_catalog_version', CATALOG_VERSION) + } catch (e) {} + return fresh +} + +function calcHeaderPaddingTop() { + try { + const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() + const statusBar = (info && info.statusBarHeight) || 20 + let capsuleBottom = statusBar + 44 + if (wx.getMenuButtonBoundingClientRect) { + const rect = wx.getMenuButtonBoundingClientRect() + if (rect && rect.bottom) capsuleBottom = rect.bottom + 8 + } + const winWidth = (info && info.windowWidth) || 375 + return Math.max(Math.round(capsuleBottom * 750 / winWidth), 64) + } catch (e) { + return 88 + } +} + +function calcNavBack() { + try { + const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() + const winWidth = (info && info.windowWidth) || 375 + const px2rpx = (px) => Math.round(px * 750 / winWidth) + let top = ((info && info.statusBarHeight) || 20) + 6 + let size = 32 + if (wx.getMenuButtonBoundingClientRect) { + const rect = wx.getMenuButtonBoundingClientRect() + if (rect && rect.top) { + top = rect.top + size = rect.height || 32 + } + } + return { top: px2rpx(top), size: px2rpx(size) } + } catch (e) { + return { top: 88, size: 64 } + } +} + +function getOpenid() { + const userInfo = wx.getStorageSync('userInfo') + return (userInfo && userInfo.openid) ? userInfo.openid : 'anonymous' +} + +Page({ + data: { + drink: null, + dimensions: [], + totalPrice: 0, + extraPrice: 0, + specTextPreview: '', + headerPaddingTop: 88, + navBackTop: 88, + navBackSize: 64, + hasActiveOrder: false, + activeItemCount: 0 + }, + + onLoad(query) { + const nb = calcNavBack() + this.setData({ headerPaddingTop: calcHeaderPaddingTop(), navBackTop: nb.top, navBackSize: nb.size }) + + let drinkId = query && query.drinkId ? Number(query.drinkId) : 0 + + let payload = null + try { + const app = getApp() + if (app && app.takeAgentHandoff) { + const handoff = app.takeAgentHandoff(this.getPageId()) + payload = handoff && handoff.payload + if (handoff) { + console.log('[sku-picker] handoff', handoff) + if (!drinkId && payload && payload.drinkId) drinkId = Number(payload.drinkId) + } + } + } catch (e) {} + + if (!drinkId) { + try { drinkId = Number(wx.getStorageSync('current_sku_drink_id')) || 0 } catch (e) {} + } + + let drink = null + if (payload && payload.drinkId && Number(payload.drinkId) === drinkId && payload.skuSchema) { + drink = { + id: payload.drinkId, + name: payload.name, + price: payload.price, + description: payload.description, + categoryName: payload.categoryName, + imageUrl: payload.imageUrl, + skuSchema: payload.skuSchema + } + } else { + const catalog = loadCatalog() + drink = catalog.find(d => d.id === drinkId) + } + + if (!drink) { + wx.showToast({ title: '商品不存在', icon: 'none' }) + return + } + + const schema = drink.skuSchema || { dimensions: [] } + const dims = schema.dimensions.map(dim => { + const defVal = DEFAULT_SINGLE_SPEC[dim.key] || (dim.options[0] && dim.options[0].value) + return { + key: dim.key, + label: dim.label, + multiple: !!dim.multiple, + selectedValue: dim.multiple ? null : defVal, + selectedValues: dim.multiple ? [] : null, + options: dim.options + } + }) + this.setData({ drink, dimensions: dims }) + this._recalc() + this._checkActiveOrder() + }, + + _checkActiveOrder() { + try { + const openid = getOpenid() + const orders = wx.getStorageSync(`orders_${openid}`) || [] + const activeId = wx.getStorageSync(`active_order_${openid}`) + const order = activeId ? orders.find(o => o.orderId === activeId) : null + const items = order && order.status !== 'paid' && Array.isArray(order.items) ? order.items : [] + this.setData({ hasActiveOrder: items.length > 0, activeItemCount: items.length }) + } catch (e) { + this.setData({ hasActiveOrder: false, activeItemCount: 0 }) + } + }, + + onTapBack() { + const pages = getCurrentPages() + if (pages && pages.length > 1) { + wx.navigateBack({ delta: 1 }) + } else { + wx.reLaunch({ url: '/packageWeStoreCoffee/pages/home/home' }) + } + }, + + onTapOption(e) { + const { dimKey, value } = e.currentTarget.dataset + const dims = this.data.dimensions.map(d => { + if (d.key !== dimKey) return d + if (d.multiple) { + const list = [...(d.selectedValues || [])] + const idx = list.indexOf(value) + if (idx >= 0) list.splice(idx, 1) + else list.push(value) + return { ...d, selectedValues: list } + } + return { ...d, selectedValue: value } + }) + this.setData({ dimensions: dims }) + this._recalc() + }, + + _recalc() { + const { drink, dimensions } = this.data + if (!drink) return + let extra = 0 + const labels = [] + const nextDims = dimensions.map(dim => { + const options = dim.options.map(opt => { + const selected = dim.multiple + ? (dim.selectedValues || []).indexOf(opt.value) >= 0 + : dim.selectedValue === opt.value + return Object.assign({}, opt, { _selected: selected }) + }) + if (dim.multiple) { + const names = [] + for (const opt of options) { + if (opt._selected) { + extra += (opt.extraPrice || 0) + names.push(opt.label) + } + } + if (names.length) labels.push(`${dim.label}:${names.join('+')}`) + } else { + const opt = options.find(o => o._selected) + if (opt) { + extra += (opt.extraPrice || 0) + labels.push(opt.label) + } + } + return Object.assign({}, dim, { options }) + }) + this.setData({ + dimensions: nextDims, + extraPrice: extra, + totalPrice: drink.price + extra, + specTextPreview: labels.join(' / ') + }) + }, + + onTapConfirm(e) { + const mode = (e && e.currentTarget && e.currentTarget.dataset.mode) || 'append' + const { drink, dimensions, specTextPreview, totalPrice, extraPrice } = this.data + if (!drink) return + for (const dim of dimensions) { + if (!dim.multiple && !dim.selectedValue) { + wx.showToast({ title: `请选择${dim.label}`, icon: 'none' }) + return + } + } + + const item = { + itemId: 'IT' + Date.now().toString(36).toUpperCase() + Math.floor(Math.random() * 1e4).toString(36).toUpperCase(), + drinkId: drink.id, + drinkName: drink.name, + imageUrl: drink.imageUrl, + specText: specTextPreview, + basePrice: drink.price, + extraPrice, + totalPrice + } + + let orderId + try { + const openid = getOpenid() + const ordersKey = `orders_${openid}` + const activeKey = `active_order_${openid}` + const orders = wx.getStorageSync(ordersKey) || [] + const activeId = wx.getStorageSync(activeKey) + + let order = null + if (mode !== 'new' && activeId) { + const found = orders.find(o => o.orderId === activeId) + if (found && found.status !== 'paid') order = found + } + + if (order) { + order.items = Array.isArray(order.items) ? order.items : [] + order.items.push(item) + } else { + order = { + orderId: 'LOCAL_' + Date.now(), + items: [item], + status: 'pending', + createTime: new Date().toISOString() + } + orders.push(order) + } + order.itemCount = order.items.length + order.totalPrice = order.items.reduce((sum, it) => sum + (Number(it.totalPrice) || 0), 0) + + const idx = orders.findIndex(o => o.orderId === order.orderId) + if (idx >= 0) orders[idx] = order + wx.setStorageSync(ordersKey, orders) + wx.setStorageSync(activeKey, order.orderId) + orderId = order.orderId + } catch (err) { + console.warn('[sku-picker] save order fail', err) + orderId = 'LOCAL_' + Date.now() + } + + wx.navigateTo({ url: `/packageWeStoreCoffee/pages/checkout/checkout?orderId=${orderId}` }) + } +}) diff --git a/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.json b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.json new file mode 100644 index 00000000..5f5af990 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.json @@ -0,0 +1,8 @@ +{ + "navigationStyle": "custom", + "navigationBarTitleText": "选规格", + "darkmode": true, + "usingComponents": {}, + "componentFramework": "glass-easel", + "renderer": "skyline" +} diff --git a/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxml b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxml new file mode 100644 index 00000000..6cd9a79f --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxml @@ -0,0 +1,61 @@ + + + + + + + + {{drink.name}} + {{drink.description}} + 基础价 ¥{{drink.price}} + + + + + + + {{dim.label}} + 可多选 + + + + {{opt.label}} + +¥{{opt.extraPrice}} + + + + + + + + + + {{specTextPreview}} + + + ¥{{totalPrice}} + (含加价 ¥{{extraPrice}}) + + + + + 新建订单 + + + 加入当前订单({{activeItemCount}}件) + + + + 加入订单 + + + diff --git a/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxss b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxss new file mode 100644 index 00000000..41be4355 --- /dev/null +++ b/miniprogram/packageWeStoreCoffee/pages/sku-picker/sku-picker.wxss @@ -0,0 +1,299 @@ +page { + background-color: #FFF8F0; + height: 100%; + overflow-x: hidden; +} + +.sp-page { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow-x: hidden; + background: linear-gradient(180deg, #FFF8F0 0%, #F5EFE6 100%); +} + +.nav-back { + position: fixed; + left: 24rpx; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 2rpx 10rpx rgba(92, 58, 33, 0.12); +} + +.nav-back-arrow { + width: 16rpx; + height: 16rpx; + border-left: 6rpx solid #2A1810; + border-bottom: 6rpx solid #2A1810; + transform: translateX(3rpx) rotate(45deg); +} + +.nav-back-hover { opacity: 0.7; } + +@media (prefers-color-scheme: dark) { + .nav-back { + background: rgba(30, 30, 30, 0.92); + box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.3); + } + .nav-back-arrow { + border-left-color: #E8E0D8; + border-bottom-color: #E8E0D8; + } +} + +.sp-header { + display: flex; + padding: 32rpx; + background: #FFFFFF; + border-bottom: 2rpx solid #EDE3D4; + flex-shrink: 0; +} + +.sp-thumb { + width: 140rpx; + height: 140rpx; + border-radius: 20rpx; + background-color: #F5EFE6; + flex-shrink: 0; +} + +.sp-head-info { + margin-left: 24rpx; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; +} + +.sp-name { + font-size: 34rpx; + font-weight: 700; + color: #2A1810; +} + +.sp-desc { + font-size: 24rpx; + color: #6B5544; + margin-top: 8rpx; + line-height: 1.4; +} + +.sp-base-price { + font-size: 24rpx; + color: #8B5E3C; + margin-top: 10rpx; +} + +.sp-body { + flex: 1; + padding: 20rpx 32rpx 0; + box-sizing: border-box; +} + +.sp-section { + margin-bottom: 28rpx; +} + +.sp-section-title { + display: flex; + align-items: baseline; + margin-bottom: 16rpx; +} + +.sp-label { + font-size: 28rpx; + font-weight: 600; + color: #2A1810; +} + +.sp-label-hint { + font-size: 22rpx; + color: #A89180; + margin-left: 12rpx; +} + +.sp-options { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.sp-option { + display: inline-flex; + align-items: center; + padding: 16rpx 24rpx; + background: #FFFFFF; + border: 2rpx solid #EDE3D4; + border-radius: 999rpx; + font-size: 26rpx; + color: #2A1810; +} + +.sp-option-active { + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + border-color: transparent; + color: #FFFFFF; +} + +.opt-hover { opacity: 0.85; } + +.sp-opt-label { + line-height: 1; +} + +.sp-opt-extra { + font-size: 22rpx; + margin-left: 8rpx; + color: inherit; + opacity: 0.85; +} + +.sp-bottom-spacer { + height: 40rpx; +} + +.sp-footer { + background: #FFFFFF; + border-top: 2rpx solid #EDE3D4; + padding: 20rpx 32rpx; + display: flex; + align-items: center; + gap: 20rpx; +} + +.safe-area-bottom { + padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx); +} + +.sp-summary { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.sp-summary-line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sp-summary-spec { + font-size: 22rpx; + color: #6B5544; +} + +.sp-summary-price-row { + display: flex; + align-items: baseline; + margin-top: 4rpx; +} + +.sp-summary-price { + font-size: 40rpx; + font-weight: 800; + color: #5C3A21; +} + +.sp-summary-extra { + font-size: 20rpx; + color: #A89180; + margin-left: 10rpx; +} + +.sp-confirm { + padding: 22rpx 40rpx; + border-radius: 999rpx; + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + flex-shrink: 0; +} + +.sp-confirm-secondary { + background: #FFFFFF; + border: 2rpx solid #C9A88A; + padding: 20rpx 32rpx; +} + +.btn-hover { opacity: 0.85; } + +.sp-confirm-text { + color: #FFFFFF; + font-size: 28rpx; + font-weight: 700; + letter-spacing: 2rpx; +} + +.sp-confirm-text-secondary { + color: #8B5E3C; + letter-spacing: 0; +} + +@media (prefers-color-scheme: dark) { + page { + background-color: #121212; + } + .sp-page { + background: linear-gradient(180deg, #121212 0%, #1A1510 100%); + } + .sp-header { + background: #1E1E1E; + border-bottom-color: #3E3228; + } + .sp-thumb { + background-color: #2A2A2A; + } + .sp-name { + color: #E8E0D8; + } + .sp-desc { + color: #9E8E7E; + } + .sp-base-price { + color: #D4A574; + } + .sp-label { + color: #E8E0D8; + } + .sp-label-hint { + color: #7A6E62; + } + .sp-option { + background: #1E1E1E; + border-color: #3E3228; + color: #E8E0D8; + } + .sp-option-active { + background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); + border-color: transparent; + color: #FFFFFF; + } + .sp-footer { + background: #1E1E1E; + border-top-color: #3E3228; + } + .sp-summary-spec { + color: #9E8E7E; + } + .sp-summary-price { + color: #D4A574; + } + .sp-summary-extra { + color: #7A6E62; + } + .sp-confirm { + background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); + } + .sp-confirm-secondary { + background: #1E1E1E; + border-color: #6B5038; + } + .sp-confirm-text-secondary { + color: #D4A574; + } +} diff --git a/miniprogram/page-meta.json b/miniprogram/page-meta.json new file mode 100644 index 00000000..235a2634 --- /dev/null +++ b/miniprogram/page-meta.json @@ -0,0 +1,9 @@ +{ + "pages": [ + { + "path": "packageWeStoreCoffee/pages/home/home", + "name": "WeStoreCafe 点单首页", + "description": "饮品商城首页,展示点单入口,用户可通过 Agent 对话完成饮品推荐、规格选择与下单支付" + } + ] +} diff --git a/miniprogram/pages/protocol/privacy.js b/miniprogram/pages/protocol/privacy.js new file mode 100644 index 00000000..36206877 --- /dev/null +++ b/miniprogram/pages/protocol/privacy.js @@ -0,0 +1,5 @@ +Page({ + data: { + title: '隐私政策' + } +}) diff --git a/miniprogram/pages/protocol/privacy.json b/miniprogram/pages/protocol/privacy.json new file mode 100644 index 00000000..d5e27792 --- /dev/null +++ b/miniprogram/pages/protocol/privacy.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "隐私政策" +} diff --git a/miniprogram/pages/protocol/privacy.wxml b/miniprogram/pages/protocol/privacy.wxml new file mode 100644 index 00000000..bec44d2e --- /dev/null +++ b/miniprogram/pages/protocol/privacy.wxml @@ -0,0 +1,7 @@ + + {{title}} + + 本页用于 Agent 隐私授权卡片(wx.requestAgentPrivacyAuthorization)的协议跳转验证,协议由调用方通过 privacyAgreements 参数传入。 + 我们会按最小必要原则处理点单所需的信息(如手机号、收货地址)。本内容为 demo 静态文案,不构成正式法律文件。 + + diff --git a/miniprogram/pages/protocol/privacy.wxss b/miniprogram/pages/protocol/privacy.wxss new file mode 100644 index 00000000..f5173f25 --- /dev/null +++ b/miniprogram/pages/protocol/privacy.wxss @@ -0,0 +1,25 @@ +.protocol-page { + padding: 32rpx; + min-height: 100vh; + background: #FFF8F0; +} + +.protocol-title { + font-size: 40rpx; + font-weight: 600; + color: #5C3A21; + margin-bottom: 24rpx; +} + +.protocol-body { + display: flex; + flex-direction: column; + gap: 20rpx; + font-size: 28rpx; + line-height: 1.6; + color: #2A1810; +} + +.protocol-para { + color: #6B5544; +} diff --git a/miniprogram/pages/protocol/user-service.js b/miniprogram/pages/protocol/user-service.js new file mode 100644 index 00000000..eedc9ba6 --- /dev/null +++ b/miniprogram/pages/protocol/user-service.js @@ -0,0 +1,5 @@ +Page({ + data: { + title: '用户服务协议' + } +}) diff --git a/miniprogram/pages/protocol/user-service.json b/miniprogram/pages/protocol/user-service.json new file mode 100644 index 00000000..3c98e8d9 --- /dev/null +++ b/miniprogram/pages/protocol/user-service.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "用户服务协议" +} diff --git a/miniprogram/pages/protocol/user-service.wxml b/miniprogram/pages/protocol/user-service.wxml new file mode 100644 index 00000000..507f53a6 --- /dev/null +++ b/miniprogram/pages/protocol/user-service.wxml @@ -0,0 +1,7 @@ + + {{title}} + + 本页用于 Agent 隐私授权卡片(wx.requestAgentPrivacyAuthorization)的协议跳转验证,协议由调用方通过 privacyAgreements 参数传入。 + 请在使用 WeStoreCafe 点单服务前阅读并同意本协议。本内容为 demo 静态文案,不构成正式法律文件。 + + diff --git a/miniprogram/pages/protocol/user-service.wxss b/miniprogram/pages/protocol/user-service.wxss new file mode 100644 index 00000000..f5173f25 --- /dev/null +++ b/miniprogram/pages/protocol/user-service.wxss @@ -0,0 +1,25 @@ +.protocol-page { + padding: 32rpx; + min-height: 100vh; + background: #FFF8F0; +} + +.protocol-title { + font-size: 40rpx; + font-weight: 600; + color: #5C3A21; + margin-bottom: 24rpx; +} + +.protocol-body { + display: flex; + flex-direction: column; + gap: 20rpx; + font-size: 28rpx; + line-height: 1.6; + color: #2A1810; +} + +.protocol-para { + color: #6B5544; +} diff --git a/miniprogram/skills/agent-auth-skill/SKILL.md b/miniprogram/skills/agent-auth-skill/SKILL.md new file mode 100644 index 00000000..2a32b255 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/SKILL.md @@ -0,0 +1,46 @@ +# agent-auth-skill Agent 隐私授权卡片 + +> 本 SKILL 覆盖小微场景的隐私授权卡片,以及订阅消息、蓝牙授权、微信运动、收货地址。 +> 隐私授权调用 `wx.requestAgentPrivacyAuthorization` 拉起客户端授权窗,**不走 handoff、不出小程序卡片**。 + +## 原子接口 + +| 接口 | 覆盖点 | +|------|--------| +| requirePrivacyAuthWithAgreements | 调用时传入两项协议 | +| requirePrivacyAuthWithoutAgreements | 不传协议,仅显示描述文案 | +| requirePrivacyAuthInvalidAgreements | trim 合法项并丢弃非法协议项 | +| requirePrivacyAuthConcurrent | 并发共享、首发协议决定卡片内容 | +| requestSubscribeMessage | `wx.requestSubscribeMessage`,tmplId 固定 | +| authorizeBluetooth | `wx.authorize({ scope: 'scope.bluetooth' })` | +| getWeRunData | `wx.login` + `scope.werun` + `wx.getWeRunData` | +| chooseAddress | `wx.chooseAddress` | + +## 契约约定(与基础库一致) + +- 开发者调用 `wx.requestAgentPrivacyAuthorization({ privacyAgreements })` 时直接传入协议列表;不再从 `app.json` 读取协议。 +- `privacyAgreements` 每项格式为 `{ name: string, path: string }`。基础库负责 trim、丢弃非法项、为 path 补 `.html`、Base64 编码并生成受控协议链接。 +- 不再支持 `type`,不区分 `default` / `phoneLogin`,统一使用 default 文案模板。 +- 卡片正文只包含统一描述与可选协议链接行,不再包含 notice 段。 +- `privacyAgreements` 缺省或清洗后为空时,卡片只显示描述文案。 +- 同一批并发调用只展示一张卡;首发调用的协议列表决定卡片内容,后续调用仅订阅结果。 +- 开发者不能传标题、正文、按钮文案、富文本或协议 URL。 +- 仅原子接口 SubContext 可用(Render / DynamicRender 域不提供);原子接口全局 TTL 到期后卡片置为已过期并 reject。 + +## 能力边界 + +- 本 skill 负责:Agent 隐私授权卡片、订阅消息、蓝牙授权、微信运动、收货地址选择。 +- 正常隐私授权使用 `requirePrivacyAuthWithAgreements` 或 `requirePrivacyAuthWithoutAgreements`。 +- `requirePrivacyAuthInvalidAgreements` / `requirePrivacyAuthConcurrent` 是验证用接口,只在用户明确要求对应验证时调用。 +- 用户拒绝授权、订阅或取消选择地址后,一句话告知结果,禁止重复拉起对应面板。 + +## 用户意图分流 + +- 拉起包含协议链接的隐私授权卡片 → `requirePrivacyAuthWithAgreements` +- 验证无协议列表的卡片 → `requirePrivacyAuthWithoutAgreements` +- 验证协议参数清洗 → `requirePrivacyAuthInvalidAgreements` +- 验证并发共享与首发协议生效 → `requirePrivacyAuthConcurrent` +- 订阅消息 / 消息通知 → `requestSubscribeMessage`(tmplId 固定,禁止改) +- 蓝牙授权 / 打开蓝牙权限 → `authorizeBluetooth` +- 微信运动 / 步数 → `getWeRunData` +- 选择收货地址 / 打开地址选择器 → `chooseAddress` diff --git a/miniprogram/skills/agent-auth-skill/apis/authorizeBluetooth.js b/miniprogram/skills/agent-auth-skill/apis/authorizeBluetooth.js new file mode 100644 index 00000000..2cbb6cc0 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/authorizeBluetooth.js @@ -0,0 +1,58 @@ +const { assertType, runAssertions } = require('../utils/assert.js') +const { wxCall, errMsgOf } = require('../utils/wxCall.js') + +const SCOPE = 'scope.bluetooth' + +async function authorizeBluetooth() { + if (typeof wx.authorize !== 'function') { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.authorize,无法发起蓝牙授权。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + authorized: false, + scope: SCOPE, + errMsg: 'wx.authorize is not a function' + } + } + } + + try { + const res = await wxCall(wx.authorize, { scope: SCOPE }) + const result = { + available: true, + authorized: true, + scope: SCOPE, + errMsg: (res && res.errMsg) || 'authorize:ok' + } + const assertions = runAssertions([ + ['errMsg 是 string', () => assertType(result.errMsg, 'string')] + ]) + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? '用户已同意蓝牙授权(scope.bluetooth)。请用一句简短话术告知用户授权完成。' + : `蓝牙授权断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = errMsgOf(err) + console.error('[authorizeBluetooth] error', err) + return { + isError: true, + content: [{ type: 'text', text: `蓝牙授权失败:${errMsg}。请用一句简短话术告知用户未获得蓝牙权限,禁止重复发起授权。` }], + structuredContent: { + available: true, + authorized: false, + scope: SCOPE, + errMsg + } + } + } +} + +module.exports = authorizeBluetooth diff --git a/miniprogram/skills/agent-auth-skill/apis/chooseAddress.js b/miniprogram/skills/agent-auth-skill/apis/chooseAddress.js new file mode 100644 index 00000000..fcdb63db --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/chooseAddress.js @@ -0,0 +1,63 @@ +const { assertType, runAssertions } = require('../utils/assert.js') +const { wxCall, errMsgOf } = require('../utils/wxCall.js') + +async function chooseAddress() { + if (typeof wx.chooseAddress !== 'function') { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.chooseAddress。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + selected: false, + errMsg: 'wx.chooseAddress is not a function' + } + } + } + + try { + const res = await wxCall(wx.chooseAddress) + const result = { + available: true, + selected: true, + userName: (res && res.userName) || '', + telNumber: (res && res.telNumber) || '', + postalCode: (res && res.postalCode) || '', + provinceName: (res && res.provinceName) || '', + cityName: (res && res.cityName) || '', + countyName: (res && res.countyName) || '', + streetName: (res && res.streetName) || '', + detailInfo: (res && res.detailInfo) || '', + nationalCode: (res && res.nationalCode) || '', + errMsg: (res && res.errMsg) || 'chooseAddress:ok' + } + const assertions = runAssertions([ + ['userName 是 string', () => assertType(result.userName, 'string')], + ['telNumber 是 string', () => assertType(result.telNumber, 'string')] + ]) + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? `用户已选择收货地址:${result.userName} ${result.telNumber} ${result.provinceName}${result.cityName}${result.countyName}${result.detailInfo}。请用一句简短话术告知用户地址已选好。` + : `收货地址断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = errMsgOf(err) + console.error('[chooseAddress] error', err) + return { + isError: true, + content: [{ type: 'text', text: `选择收货地址失败:${errMsg}。请用一句简短话术告知用户未选择地址,禁止重复拉起。` }], + structuredContent: { + available: true, + selected: false, + errMsg + } + } + } +} + +module.exports = chooseAddress diff --git a/miniprogram/skills/agent-auth-skill/apis/getWeRunData.js b/miniprogram/skills/agent-auth-skill/apis/getWeRunData.js new file mode 100644 index 00000000..046383bc --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/getWeRunData.js @@ -0,0 +1,71 @@ +const { assertType, runAssertions } = require('../utils/assert.js') +const { wxCall, errMsgOf } = require('../utils/wxCall.js') + +const SCOPE = 'scope.werun' + +async function getWeRunData() { + if (typeof wx.getWeRunData !== 'function') { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.getWeRunData。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + authorized: false, + encryptedData: '', + iv: '', + cloudID: '', + errMsg: 'wx.getWeRunData is not a function' + } + } + } + + try { + if (typeof wx.login === 'function') { + await wxCall(wx.login) + } + if (typeof wx.authorize === 'function') { + await wxCall(wx.authorize, { scope: SCOPE }) + } + + const res = await wxCall(wx.getWeRunData) + const result = { + available: true, + authorized: true, + encryptedData: (res && res.encryptedData) || '', + iv: (res && res.iv) || '', + cloudID: (res && res.cloudID) || '', + errMsg: (res && res.errMsg) || 'getWeRunData:ok' + } + const assertions = runAssertions([ + ['encryptedData 是 string', () => assertType(result.encryptedData, 'string')] + ]) + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? '已获取微信运动加密数据(需后端解密后才是明文步数)。请用一句简短话术告知用户已拿到运动数据,不要在对话里展开 encryptedData。' + : `获取微信运动断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = errMsgOf(err) + console.error('[getWeRunData] error', err) + return { + isError: true, + content: [{ type: 'text', text: `获取微信运动失败:${errMsg}。请用一句简短话术告知用户未获得运动数据,禁止重复发起授权。` }], + structuredContent: { + available: true, + authorized: false, + encryptedData: '', + iv: '', + cloudID: '', + errMsg + } + } + } +} + +module.exports = getWeRunData diff --git a/miniprogram/skills/agent-auth-skill/apis/requestSubscribeMessage.js b/miniprogram/skills/agent-auth-skill/apis/requestSubscribeMessage.js new file mode 100644 index 00000000..3dd3f7c5 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/requestSubscribeMessage.js @@ -0,0 +1,67 @@ +const { assertType, runAssertions } = require('../utils/assert.js') +const { wxCall, errMsgOf } = require('../utils/wxCall.js') + +const TMPL_ID = 'y1bXHAg_oDuvrQ3pHgcODcMPl-2hZHenWugsqdB2CXY' + +async function requestSubscribeMessage() { + if (typeof wx.requestSubscribeMessage !== 'function') { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.requestSubscribeMessage。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + tmplId: TMPL_ID, + status: '', + errMsg: 'wx.requestSubscribeMessage is not a function' + } + } + } + + try { + const res = await wxCall(wx.requestSubscribeMessage, { tmplIds: [TMPL_ID] }) + const status = res && typeof res[TMPL_ID] === 'string' ? res[TMPL_ID] : '' + const result = { + available: true, + tmplId: TMPL_ID, + status, + accepted: status === 'accept', + errMsg: (res && res.errMsg) || '' + } + const assertions = runAssertions([ + ['errMsg 是 string', () => assertType(result.errMsg, 'string')] + ]) + + let summary = `订阅消息面板已返回,模板 ${TMPL_ID} 的结果为 ${status || '未知'}。` + if (status === 'accept') summary = '用户已同意订阅该模板消息。请用一句简短话术告知用户订阅成功。' + else if (status === 'reject') summary = '用户拒绝订阅该模板消息。请用一句简短话术告知用户未订阅,禁止重复拉起订阅面板。' + else if (status === 'ban') summary = '该模板消息已被后台封禁,无法订阅。请直接告知用户当前无法订阅。' + else if (status === 'filter') summary = '该模板因标题同名被过滤。请直接告知用户当前无法订阅。' + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? summary + : `订阅消息断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = errMsgOf(err) + console.error('[requestSubscribeMessage] error', err) + return { + isError: true, + content: [{ type: 'text', text: `订阅消息失败:${errMsg}。请直接告知用户订阅未完成,禁止重试本接口。` }], + structuredContent: { + available: true, + tmplId: TMPL_ID, + status: '', + accepted: false, + errMsg + } + } + } +} + +module.exports = requestSubscribeMessage diff --git a/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthConcurrent.js b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthConcurrent.js new file mode 100644 index 00000000..651eb3da --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthConcurrent.js @@ -0,0 +1,85 @@ +const { assertEquals, assertTrue, assertType, runAssertions } = require('../utils/assert.js') +const { + isAgentPrivacyAuthorizeAvailable, + callAgentPrivacyAuthorize +} = require('../utils/privacyAuthorize.js') + +const FIRST_AGREEMENTS = [ + { name: '用户服务协议', path: 'pages/protocol/user-service' } +] +const LATER_AGREEMENTS = [ + { name: '隐私政策', path: 'pages/protocol/privacy' } +] + +async function requirePrivacyAuthConcurrent() { + if (!isAgentPrivacyAuthorizeAvailable()) { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.requestAgentPrivacyAuthorization,无法验证并发共享。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + count: 0, + authorizedList: [], + allSame: false, + authorized: false, + errMsg: 'wx.requestAgentPrivacyAuthorization is not a function' + } + } + } + + try { + const startedAt = Date.now() + const results = await Promise.all([ + callAgentPrivacyAuthorize({ privacyAgreements: FIRST_AGREEMENTS }), + callAgentPrivacyAuthorize({ privacyAgreements: LATER_AGREEMENTS }), + callAgentPrivacyAuthorize() + ]) + const authorizedList = results.map(item => !!item.authorized) + const authorized = authorizedList[0] + const allSame = authorizedList.every(item => item === authorized) + const result = { + available: true, + count: results.length, + authorizedList, + allSame, + authorized, + firstAgreementNames: FIRST_AGREEMENTS.map(item => item.name), + ignoredLaterAgreementNames: LATER_AGREEMENTS.map(item => item.name), + elapsedMs: Date.now() - startedAt, + errMsg: (results[0] && results[0].errMsg) || '' + } + const assertions = runAssertions([ + ['并发次数为 3', () => assertEquals(result.count, 3)], + ['全部结果一致', () => assertTrue(allSame, `authorizedList=${JSON.stringify(authorizedList)}`)], + ['authorized 是 boolean', () => assertType(authorized, 'boolean')] + ]) + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? `并发 3 次调用结果一致,卡片应采用首发协议「用户服务协议」,后续的「隐私政策」不应覆盖首发内容。请结合卡片显示确认首发协议生效。` + : `并发共享断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = (err && (err.errMsg || err.message)) || String(err) + console.error('[requirePrivacyAuthConcurrent] error', err) + return { + isError: true, + content: [{ type: 'text', text: `并发拉起隐私授权卡片失败:${errMsg}。请直接告知用户授权未完成,禁止重试本接口。` }], + structuredContent: { + available: true, + count: 3, + authorizedList: [], + allSame: false, + authorized: false, + errMsg + } + } + } +} + +module.exports = requirePrivacyAuthConcurrent diff --git a/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthInvalidAgreements.js b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthInvalidAgreements.js new file mode 100644 index 00000000..7189ad40 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthInvalidAgreements.js @@ -0,0 +1,73 @@ +const { assertType, runAssertions } = require('../utils/assert.js') +const { + isAgentPrivacyAuthorizeAvailable, + callAgentPrivacyAuthorize +} = require('../utils/privacyAuthorize.js') + +const MIXED_AGREEMENTS = [ + { name: ' 用户服务协议 ', path: ' pages/protocol/user-service ' }, + { name: '', path: 'pages/protocol/privacy' }, + { name: '隐私政策', path: ' ' }, + null, + 'invalid-item' +] + +async function requirePrivacyAuthInvalidAgreements() { + if (!isAgentPrivacyAuthorizeAvailable()) { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.requestAgentPrivacyAuthorization,无法验证协议清洗。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + authorized: false, + inputAgreementCount: MIXED_AGREEMENTS.length, + expectedValidCount: 1, + errMsg: 'wx.requestAgentPrivacyAuthorization is not a function' + } + } + } + + try { + const res = await callAgentPrivacyAuthorize({ + privacyAgreements: MIXED_AGREEMENTS + }) + const result = { + available: true, + authorized: !!res.authorized, + inputAgreementCount: MIXED_AGREEMENTS.length, + expectedValidCount: 1, + expectedAgreementName: '用户服务协议', + errMsg: res.errMsg || '' + } + const assertions = runAssertions([ + ['非法协议项未导致调用失败', () => assertType(result.authorized, 'boolean')] + ]) + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? `协议清洗用例已完成:输入 ${result.inputAgreementCount} 项,卡片应仅显示 1 个有效协议链接「${result.expectedAgreementName}」。请结合卡片显示确认清洗结果。` + : `协议清洗用例断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = (err && (err.errMsg || err.message)) || String(err) + console.error('[requirePrivacyAuthInvalidAgreements] error', err) + return { + isError: true, + content: [{ type: 'text', text: `协议清洗用例失败:${errMsg}。请直接告知用户验证未完成,禁止重试本接口。` }], + structuredContent: { + available: true, + authorized: false, + inputAgreementCount: MIXED_AGREEMENTS.length, + expectedValidCount: 1, + errMsg + } + } + } +} + +module.exports = requirePrivacyAuthInvalidAgreements diff --git a/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithAgreements.js b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithAgreements.js new file mode 100644 index 00000000..24beef2b --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithAgreements.js @@ -0,0 +1,68 @@ +const { assertTrue, assertType, runAssertions } = require('../utils/assert.js') +const { + isAgentPrivacyAuthorizeAvailable, + callAgentPrivacyAuthorize +} = require('../utils/privacyAuthorize.js') + +const PRIVACY_AGREEMENTS = [ + { name: '用户服务协议', path: 'pages/protocol/user-service' }, + { name: '隐私政策', path: 'pages/protocol/privacy' } +] + +async function requirePrivacyAuthWithAgreements() { + if (!isAgentPrivacyAuthorizeAvailable()) { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.requestAgentPrivacyAuthorization,无法拉起隐私授权卡片。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + authorized: false, + agreementCount: PRIVACY_AGREEMENTS.length, + errMsg: 'wx.requestAgentPrivacyAuthorization is not a function' + } + } + } + + try { + const res = await callAgentPrivacyAuthorize({ + privacyAgreements: PRIVACY_AGREEMENTS + }) + const result = { + available: true, + authorized: !!res.authorized, + agreementCount: PRIVACY_AGREEMENTS.length, + agreementNames: PRIVACY_AGREEMENTS.map(item => item.name), + errMsg: res.errMsg || '' + } + const assertions = runAssertions([ + ['authorized 是 boolean', () => assertType(result.authorized, 'boolean')], + ['传入两项协议', () => assertTrue(result.agreementCount === 2)] + ]) + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? `隐私授权卡片已完成,传入 ${result.agreementCount} 项协议,用户${result.authorized ? '同意' : '拒绝'}授权。请用一句简短话术告知用户结果。` + : `隐私授权卡片断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = (err && (err.errMsg || err.message)) || String(err) + console.error('[requirePrivacyAuthWithAgreements] error', err) + return { + isError: true, + content: [{ type: 'text', text: `拉起隐私授权卡片失败:${errMsg}。请直接告知用户授权未完成,禁止重试本接口。` }], + structuredContent: { + available: true, + authorized: false, + agreementCount: PRIVACY_AGREEMENTS.length, + errMsg + } + } + } +} + +module.exports = requirePrivacyAuthWithAgreements diff --git a/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithoutAgreements.js b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithoutAgreements.js new file mode 100644 index 00000000..4e139401 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/apis/requirePrivacyAuthWithoutAgreements.js @@ -0,0 +1,59 @@ +const { assertType, runAssertions } = require('../utils/assert.js') +const { + isAgentPrivacyAuthorizeAvailable, + callAgentPrivacyAuthorize +} = require('../utils/privacyAuthorize.js') + +async function requirePrivacyAuthWithoutAgreements() { + if (!isAgentPrivacyAuthorizeAvailable()) { + return { + isError: true, + content: [{ type: 'text', text: '当前基础库不支持 wx.requestAgentPrivacyAuthorization,无法验证无协议授权卡片。请直接告知用户该能力暂不可用,禁止重试本接口。' }], + structuredContent: { + available: false, + authorized: false, + agreementCount: 0, + errMsg: 'wx.requestAgentPrivacyAuthorization is not a function' + } + } + } + + try { + const res = await callAgentPrivacyAuthorize() + const result = { + available: true, + authorized: !!res.authorized, + agreementCount: 0, + errMsg: res.errMsg || '' + } + const assertions = runAssertions([ + ['authorized 是 boolean', () => assertType(result.authorized, 'boolean')] + ]) + + return { + isError: !assertions.passed, + content: [{ + type: 'text', + text: assertions.passed + ? `无协议列表的隐私授权卡片已完成,卡片应仅展示描述文案,用户${result.authorized ? '同意' : '拒绝'}授权。请用一句简短话术告知用户结果。` + : `无协议授权卡片断言失败:${assertions.failures.map(item => item.errMsg).join(';')}` + }], + structuredContent: Object.assign({}, result, { assertions }) + } + } catch (err) { + const errMsg = (err && (err.errMsg || err.message)) || String(err) + console.error('[requirePrivacyAuthWithoutAgreements] error', err) + return { + isError: true, + content: [{ type: 'text', text: `拉起无协议授权卡片失败:${errMsg}。请直接告知用户授权未完成,禁止重试本接口。` }], + structuredContent: { + available: true, + authorized: false, + agreementCount: 0, + errMsg + } + } + } +} + +module.exports = requirePrivacyAuthWithoutAgreements diff --git a/miniprogram/skills/agent-auth-skill/index.js b/miniprogram/skills/agent-auth-skill/index.js new file mode 100644 index 00000000..4e27fd02 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/index.js @@ -0,0 +1,21 @@ +const requirePrivacyAuthWithAgreements = require('./apis/requirePrivacyAuthWithAgreements.js') +const requirePrivacyAuthWithoutAgreements = require('./apis/requirePrivacyAuthWithoutAgreements.js') +const requirePrivacyAuthInvalidAgreements = require('./apis/requirePrivacyAuthInvalidAgreements.js') +const requirePrivacyAuthConcurrent = require('./apis/requirePrivacyAuthConcurrent.js') +const requestSubscribeMessage = require('./apis/requestSubscribeMessage.js') +const authorizeBluetooth = require('./apis/authorizeBluetooth.js') +const getWeRunData = require('./apis/getWeRunData.js') +const chooseAddress = require('./apis/chooseAddress.js') + +const skill = wx.modelContext.createSkill('skills/agent-auth-skill') + +skill.registerAPI('requirePrivacyAuthWithAgreements', requirePrivacyAuthWithAgreements) +skill.registerAPI('requirePrivacyAuthWithoutAgreements', requirePrivacyAuthWithoutAgreements) +skill.registerAPI('requirePrivacyAuthInvalidAgreements', requirePrivacyAuthInvalidAgreements) +skill.registerAPI('requirePrivacyAuthConcurrent', requirePrivacyAuthConcurrent) +skill.registerAPI('requestSubscribeMessage', requestSubscribeMessage) +skill.registerAPI('authorizeBluetooth', authorizeBluetooth) +skill.registerAPI('getWeRunData', getWeRunData) +skill.registerAPI('chooseAddress', chooseAddress) + +console.log('[agent-auth-skill] APIs registered via createSkill') diff --git a/miniprogram/skills/agent-auth-skill/mcp.json b/miniprogram/skills/agent-auth-skill/mcp.json new file mode 100644 index 00000000..5405747e --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/mcp.json @@ -0,0 +1,176 @@ +{ + "apis": [ + { + "name": "requirePrivacyAuthWithAgreements", + "description": "调用 wx.requestAgentPrivacyAuthorization,并通过 privacyAgreements 传入「用户服务协议」和「隐私政策」。用于验证协议链接展示、跳转及授权结果。协议来源是本次接口入参,不读取 app.json。用户拒绝后禁止重复拉起。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.requestAgentPrivacyAuthorization" }, + "authorized": { "type": "boolean", "description": "用户是否同意授权" }, + "agreementCount": { "type": "number", "description": "传入的协议数量" }, + "agreementNames": { + "type": "array", + "items": { "type": "string" }, + "description": "传入的协议名称" + }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + }, + { + "name": "requirePrivacyAuthWithoutAgreements", + "description": "调用 wx.requestAgentPrivacyAuthorization,但不传 privacyAgreements。用于验证协议缺省时卡片仅显示统一描述文案、不显示协议链接行。用户拒绝后禁止重复拉起。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.requestAgentPrivacyAuthorization" }, + "authorized": { "type": "boolean", "description": "用户是否同意授权" }, + "agreementCount": { "type": "number", "description": "传入的协议数量,固定为 0" }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + }, + { + "name": "requirePrivacyAuthInvalidAgreements", + "description": "【验证用接口】向 wx.requestAgentPrivacyAuthorization 传入混合协议列表:一项带首尾空格的合法协议,以及空 name、空 path、非对象等非法项。用于验证基础库 trim 并丢弃非法项,卡片应只显示「用户服务协议」。仅在明确要求验证协议清洗时调用。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.requestAgentPrivacyAuthorization" }, + "authorized": { "type": "boolean", "description": "用户是否同意授权" }, + "inputAgreementCount": { "type": "number", "description": "输入协议项数量" }, + "expectedValidCount": { "type": "number", "description": "预期清洗后的有效协议数量" }, + "expectedAgreementName": { "type": "string", "description": "预期展示的协议名称" }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + }, + { + "name": "requirePrivacyAuthConcurrent", + "description": "【验证用接口】并发三次调用 wx.requestAgentPrivacyAuthorization:首发传「用户服务协议」,后续分别传「隐私政策」和空协议。验证只展示一张卡、结果广播一致,且首发协议决定卡片内容。仅在明确要求验证并发共享时调用。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.requestAgentPrivacyAuthorization" }, + "count": { "type": "number", "description": "并发次数" }, + "authorizedList": { + "type": "array", + "items": { "type": "boolean" }, + "description": "每次调用的授权结果" + }, + "allSame": { "type": "boolean", "description": "全部调用结果是否一致" }, + "authorized": { "type": "boolean", "description": "首个调用的授权结果" }, + "firstAgreementNames": { + "type": "array", + "items": { "type": "string" }, + "description": "首发调用传入的协议名称" + }, + "ignoredLaterAgreementNames": { + "type": "array", + "items": { "type": "string" }, + "description": "后续调用中不应覆盖首发内容的协议名称" + }, + "elapsedMs": { "type": "number", "description": "并发耗时(毫秒)" }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + }, + { + "name": "requestSubscribeMessage", + "description": "调起订阅消息面板:调用 wx.requestSubscribeMessage,模板 ID 固定为 f5H2tloldRuDwJyi164NQTb3uYUGhtwUZ_PI8wZ8Uv4。用户说「订阅消息 / 消息通知 / 打开订阅」时调用。\n【无入参】不要改 tmplId。\n【返回后动作】按 status(accept / reject / ban / filter)用一句话告知结果;用户拒绝后禁止重复拉起。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.requestSubscribeMessage" }, + "tmplId": { "type": "string", "description": "订阅消息模板 ID" }, + "status": { "type": "string", "description": "该 tmplId 的订阅结果:accept / reject / ban / filter" }, + "accepted": { "type": "boolean", "description": "用户是否同意订阅" }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + }, + { + "name": "authorizeBluetooth", + "description": "发起蓝牙授权:调用 wx.authorize({ scope: 'scope.bluetooth' })。用户说「蓝牙授权 / 打开蓝牙权限」时调用。\n【无入参】。\n【返回后动作】授权成功或失败都用一句话告知用户;用户拒绝后禁止重复发起。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.authorize" }, + "authorized": { "type": "boolean", "description": "用户是否同意蓝牙授权" }, + "scope": { "type": "string", "description": "授权 scope,固定为 scope.bluetooth" }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + }, + { + "name": "getWeRunData", + "description": "获取微信运动步数:先 wx.login,再 wx.authorize({ scope: 'scope.werun' }),最后 wx.getWeRunData。用户说「微信运动 / 步数 / 走路数据」时调用。\n【无入参】。\n【返回说明】回传 encryptedData / iv / cloudID,明文步数需后端解密,不要在对话中展开加密字段。用户拒绝后禁止重复发起。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.getWeRunData" }, + "authorized": { "type": "boolean", "description": "是否完成微信运动授权并取到数据" }, + "encryptedData": { "type": "string", "description": "加密的步数数据" }, + "iv": { "type": "string", "description": "加密初始向量" }, + "cloudID": { "type": "string", "description": "云 ID(开通云开发时返回)" }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + }, + { + "name": "chooseAddress", + "description": "调起微信收货地址选择器:调用 wx.chooseAddress。用户说「选择收货地址 / 获取收货地址 / 打开地址选择器」时调用。\n【无入参】。\n【返回后动作】按返回的姓名、电话、省市区和详细地址用一句话告知结果;用户取消后禁止重复拉起。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "available": { "type": "boolean", "description": "基础库是否支持 wx.chooseAddress" }, + "selected": { "type": "boolean", "description": "用户是否完成地址选择" }, + "userName": { "type": "string", "description": "收货人姓名" }, + "telNumber": { "type": "string", "description": "收货人手机号码" }, + "postalCode": { "type": "string", "description": "邮编" }, + "provinceName": { "type": "string", "description": "国标收货地址第一级地址" }, + "cityName": { "type": "string", "description": "国标收货地址第二级地址" }, + "countyName": { "type": "string", "description": "国标收货地址第三级地址" }, + "streetName": { "type": "string", "description": "国标收货地址第四级地址" }, + "detailInfo": { "type": "string", "description": "详细收货地址" }, + "nationalCode": { "type": "string", "description": "收货地址国家码" }, + "errMsg": { "type": "string", "description": "接口结果信息" } + } + } + } + ], + "components": [] +} diff --git a/miniprogram/skills/agent-auth-skill/utils/assert.js b/miniprogram/skills/agent-auth-skill/utils/assert.js new file mode 100644 index 00000000..ba511f52 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/utils/assert.js @@ -0,0 +1,82 @@ +class AssertionError extends Error { + constructor(message, expected, actual) { + super(message) + this.name = 'AssertionError' + this.expected = expected + this.actual = actual + } +} + +function safeStringify(value) { + try { + return JSON.stringify(value) + } catch (e) { + return String(value) + } +} + +function assertTrue(actual, message) { + if (!actual) { + throw new AssertionError( + message || `assertTrue failed, got ${safeStringify(actual)}`, + true, + actual + ) + } +} + +function assertEquals(actual, expected, message) { + if (actual !== expected) { + throw new AssertionError( + message || `assertEquals failed: expected ${safeStringify(expected)}, got ${safeStringify(actual)}`, + expected, + actual + ) + } +} + +function assertType(actual, expectedType, message) { + const t = typeof actual + if (t !== expectedType) { + throw new AssertionError( + message || `assertType failed: expected typeof === '${expectedType}', got '${t}' (value=${safeStringify(actual)})`, + expectedType, + t + ) + } +} + +function runAssertions(cases) { + const failures = [] + let total = 0 + + for (let i = 0; i < cases.length; i++) { + const c = cases[i] + if (!c || c.length < 2) continue + + const name = c[0] + const fn = c[1] + total += 1 + + try { + fn() + } catch (e) { + failures.push({ + name, + errMsg: (e && (e.message || e.errMsg)) || String(e), + expected: e && e.expected, + actual: e && e.actual + }) + } + } + + return { passed: failures.length === 0, failures, total } +} + +module.exports = { + AssertionError, + assertTrue, + assertEquals, + assertType, + runAssertions +} diff --git a/miniprogram/skills/agent-auth-skill/utils/privacyAuthorize.js b/miniprogram/skills/agent-auth-skill/utils/privacyAuthorize.js new file mode 100644 index 00000000..e415afc8 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/utils/privacyAuthorize.js @@ -0,0 +1,47 @@ +function isAgentPrivacyAuthorizeAvailable() { + return typeof wx.requestAgentPrivacyAuthorization === 'function' +} + +function callAgentPrivacyAuthorize(options) { + if (!isAgentPrivacyAuthorizeAvailable()) { + return Promise.reject({ + errMsg: 'wx.requestAgentPrivacyAuthorization is not a function' + }) + } + + const opts = options && typeof options === 'object' ? Object.assign({}, options) : {} + + return new Promise((resolve, reject) => { + let settled = false + const done = (fn, value) => { + if (settled) return + settled = true + fn(value) + } + const toResult = (res) => ({ + authorized: !!(res && res.authorized), + errMsg: (res && res.errMsg) || 'requestAgentPrivacyAuthorization:ok', + raw: res || {} + }) + + try { + const ret = wx.requestAgentPrivacyAuthorization(Object.assign({}, opts, { + success: (res) => done(resolve, toResult(res)), + fail: (err) => done(reject, err || { errMsg: 'requestAgentPrivacyAuthorization:fail' }) + })) + + if (ret && typeof ret.then === 'function') { + ret + .then((res) => done(resolve, toResult(res))) + .catch((err) => done(reject, err || { errMsg: 'requestAgentPrivacyAuthorization:fail' })) + } + } catch (e) { + done(reject, { errMsg: (e && (e.errMsg || e.message)) || String(e) }) + } + }) +} + +module.exports = { + isAgentPrivacyAuthorizeAvailable, + callAgentPrivacyAuthorize +} diff --git a/miniprogram/skills/agent-auth-skill/utils/wxCall.js b/miniprogram/skills/agent-auth-skill/utils/wxCall.js new file mode 100644 index 00000000..6d9c2b95 --- /dev/null +++ b/miniprogram/skills/agent-auth-skill/utils/wxCall.js @@ -0,0 +1,38 @@ +function wxCall(fn, options) { + if (typeof fn !== 'function') { + return Promise.reject({ errMsg: 'wx API is not a function' }) + } + + const opts = options && typeof options === 'object' ? Object.assign({}, options) : {} + + return new Promise((resolve, reject) => { + let settled = false + const done = (next, value) => { + if (settled) return + settled = true + next(value) + } + + try { + const ret = fn(Object.assign({}, opts, { + success: (res) => done(resolve, res || {}), + fail: (err) => done(reject, err || { errMsg: 'fail' }) + })) + + if (ret && typeof ret.then === 'function') { + ret.then((res) => done(resolve, res || {})).catch((err) => done(reject, err || { errMsg: 'fail' })) + } + } catch (e) { + done(reject, { errMsg: (e && (e.errMsg || e.message)) || String(e) }) + } + }) +} + +function errMsgOf(err) { + return (err && (err.errMsg || err.message)) || String(err) +} + +module.exports = { + wxCall, + errMsgOf +} diff --git a/miniprogram/skills/drink-skill/SKILL.md b/miniprogram/skills/drink-skill/SKILL.md new file mode 100644 index 00000000..692c936e --- /dev/null +++ b/miniprogram/skills/drink-skill/SKILL.md @@ -0,0 +1,103 @@ +# drink-skill WeStoreCafe 点单场景(小微 AI Handoff 模式) + +> 本 SKILL 已适配「小微 SubAgent handoff」:对话内只展示**文本回复 + 小程序卡片**, +> 用户点击小程序卡片后 **handoff 接力**进入开发者业务页(接力页),在小程序内完成后续操作。 +> 小微对话内**不渲染原子组件**;组件代码与 `componentPath` 保留以便后续复用 / 小程序内 Agent 场景。 + +## 业务流程图(handoff 模式) + +``` +用户意图 + │ + ├─ 明确关键词/品类("拿铁"/"果汁")─→ searchDrinks ──┐ 文本 + 小程序卡片 + │ (模糊意图 keyword 留空,返回全部饮品) │ (点击 handoff 进列表页) + │ │ + │ 点击卡片 → 接力页 home(首页浏览/搜索/筛选) + │ │ navigateTo(小程序内) + │ ↓ + ├─ 已知 drinkId(看详情)──→ selectDrink ──→ 文本 + 小程序卡片 + │ 点击卡片 → 接力页 sku-picker(详情/选规格) + │ │ 加入订单 → navigateTo(小程序内) + │ ↓ + ├─ 新点一杯 / 换一杯(新建订单)─→ createOrder ─→ 文本 + 小程序卡片 + │ 点击卡片 → 接力页 checkout(地址 + 支付,展示全部已选商品) + │ ↑ + ├─ 再加一杯 / 追加(已有订单)──→ addToOrder ──→ 文本 + 小程序卡片(同上,商品列表已含追加项) + │ │ + ├─ 删掉某杯(订单>1件)──────→ removeOrderItem ─→ 文本 + 小程序卡片(同上,商品列表已移除该项) + │ ↓ + │ 完成下单(小程序内 wx.chooseAddress / wx.requestPayment) + │ + └─ 对话中直接给出地址 ────→ saveAddress ──→ 文本 + 小程序卡片 + 点击卡片 → 接力页 checkout(预填地址,确认支付) +``` + +> 注:上图中 `createOrder` / `addToOrder` 并非独立入口,均需**先经 `selectDrink`** 拿到目标饮品的 drinkId 与规格后才能调用(见「业务约束 2. 执行顺序」)。 + +## 原子接口 ↔ 接力页映射 + +| 接口 | 作用 | handoff.query | 接力页 pagePath | +|------|------|---------------|-----------------| +| searchDrinks | 关键词搜索 / 浏览全部 | `keyword=拿铁`(可留空=全部) | /packageWeStoreCoffee/pages/home/home | +| selectDrink | 饮品详情 + 规格 | `drinkId=289` | /packageWeStoreCoffee/pages/sku-picker/sku-picker | +| createOrder | 新建订单(首杯/换一杯) | `orderId=xxx` | /packageWeStoreCoffee/pages/checkout/checkout | +| addToOrder | 追加商品到当前订单 | `orderId=xxx` | /packageWeStoreCoffee/pages/checkout/checkout | +| removeOrderItem | 删除订单内某商品(至少保留一件) | `orderId=xxx` | /packageWeStoreCoffee/pages/checkout/checkout | +| viewOrder | 查看当前进行中的订单 | `orderId=xxx` | /packageWeStoreCoffee/pages/checkout/checkout | +| cancelOrder | 取消/清空整单 | 空(回首页) | /packageWeStoreCoffee/pages/home/home | +| saveAddress | 保存地址 → 结算页 | `orderId=xxx` | /packageWeStoreCoffee/pages/checkout/checkout | + +> Agent 授权卡片能力已拆至独立 skill:`skills/agent-auth-skill`(`wx.requestAgentPrivacyAuthorization`)。 + +## 业务约束(跨接口铁律) + +### 0. 能力边界(最高优先级) +- 本 skill 只能完成「饮品检索 / 看详情选规格 / 下单 / 追加或删除订单商品 / 查看订单 / 取消整单 / 保存地址 → 结算」这条点单链路。以下原子接口是**全部能力**:`searchDrinks`、`selectDrink`、`createOrder`、`addToOrder`、`removeOrderItem`、`viewOrder`、`cancelOrder`、`saveAddress`。 +- **当用户意图无法用上述接口完成时(超出点单范围,或需要本 skill 不具备的能力),必须直接用一句话如实回复用户"该能力暂不支持 / 无法完成",禁止为了"凑一个动作"而擅自调用任何原子接口。** +- 典型不支持场景(示例,非穷举):查询/取消/退款历史订单、修改已支付订单、申请发票、查配送进度、改店铺/门店、会员积分/优惠券、投诉客服、菜单以外的商品等。遇到这类请求 → 直接文本说明不支持,不要硬套 searchDrinks/createOrder 等接口。 +- 判断依据:**只有当用户意图能明确对应到某个接口的真实语义时才调用**;无法对应就回复用户,宁可不调用,也不要用错接口或编造参数去调用。 + +### 1. 输出形态(小微) +- 对话内**只输出文本 + 小程序卡片**,禁止以 markdown 列表展开商品名 / 价格 / 规格等详情。 +- 已进入点单流程(调用了原子接口)时:只用一句简短话术引导用户,具体商品 / 订单详情交给小程序卡片与接力页展示。 +- 用户诉求与点单无关时:正常用文本回复即可。 + +### 2. 执行顺序 +- `createOrder` / `addToOrder` 必须在 `selectDrink` 成功后调用(上下文中需有目标饮品的 drinkId 与 specOptions)。 +- **单次仅支持单杯**:`createOrder` / `addToOrder` 每次调用**只处理一杯饮品**(一个 drinkId + 一套 specs),不支持在一次调用里下多杯或多款。用户一次要多杯时,需**逐杯依次调用**——第一杯用 `createOrder`(或 addToOrder 新建),其余每杯各调一次 `addToOrder` 追加;不要试图用数量字段或数组一次性下单。 +- `addToOrder`:有进行中的活动订单则追加;**若当前没有活动订单,则以本杯新建订单**(追加意图下调用总是安全的,不会因无订单而报错)。 +- `drinkId` 来自 searchDrinks / selectDrink 的返回值(createOrder / addToOrder 用上一步 `selectDrink` 返回的 drinkId);`orderId` 来自 createOrder / addToOrder 的返回值。禁止编造。 +- 规格值用英文枚举(如 `ice` / `hot`),禁止用中文。 + +### 3. 进小程序后的后续 +- 选规格、补地址、支付等后续操作**均在小程序接力页内完成**,不再回到对话调用原子接口。 +- 例外:用户在**进小程序前的对话中**就直接给出了完整收货地址,则走 `saveAddress` 在对话侧保存(见「用户意图分流」);进接力页后以页面内选择的地址为准。 + +### 4. 单次购买语义(整链路只维护一个活动订单,可含多杯) +- 整个点单链路(searchDrinks → selectDrink → createOrder / addToOrder → saveAddress)**只针对单次购买**:同一时刻只存在一个「活动订单」,但该订单**可以包含多杯饮品**(items 列表)。 +- `createOrder`:**新建**订单,以当前饮品为首个商品项,并**覆盖**之前的活动订单。用户「就要这一杯 / 换一杯 / 重新点」走这里。 +- `addToOrder`:向**当前活动订单**追加一杯;若当前没有活动订单则以本杯新建订单。用户「再加一杯 / 还要一个 / 顺便来个X」走这里。 +- `removeOrderItem`:从当前活动订单删除一件商品,按 `itemId` 定位(itemId 来自订单接口返回的商品项)。**铁律:订单至少保留一件**,仅剩一件时禁止删除,直接告知用户「仅剩一件,无法删除」。 +- 活动订单从建单起一直有效,直到用户在结算页**支付完成**才失效;之后再 addToOrder 会开一个新订单。 +- `saveAddress` 只会把地址写入**当前**活动订单并更新为 confirmed。 + +### 5. 修改商品规格(先删后加,单件例外) +- 用户要改某已选商品的规格(如「把美式换成大杯」)时: + - **订单有多件** → 先 `removeOrderItem`(旧商品项的 itemId)→ 再 `addToOrder`(同一 drinkId + 新规格)。 + - **订单仅此一件** → 最后一件删不掉,直接用 `createOrder`(同一 drinkId + 新规格)**覆盖**整单,不要调用 removeOrderItem。 +- `addToOrder` / `createOrder` 用到的 drinkId 沿用被改商品的 drinkId(已在订单/上下文中),新规格取用户本次表达的值。 + +## 用户意图分流 + +- 模糊表达("想喝/推荐/随便看看")→ `searchDrinks`(keyword 留空返回全部) +- 具体品名 / 品类 → `searchDrinks`(keyword 填用户原话) +- 已知某饮品要看详情 → `selectDrink` +- 新点一杯 / 换一杯 / 重新点(无进行中订单,或明确要重来)→ `selectDrink` 后 `createOrder` +- 再加一杯 / 还要一个 / 顺便来个X(追加到订单)→ `selectDrink` 后 `addToOrder`(有活动订单则追加,无则以本杯新建,调用总是安全的) +- 删掉/去掉/不要某杯 → `removeOrderItem`(按 itemId);**仅剩一件时告知用户无法删除** +- 取消订单 / 不要了 / 清空购物车 / 全部删掉 → `cancelOrder`(清空整单,回首页);这也是"删最后一件/清空全部"的正确出口 +- 查看订单 / 我点了啥 / 购物车里有什么 / 一共多少钱 → `viewOrder`(无进行中订单则告知用户暂无订单) +- 改某杯规格 → 见「5. 修改商品规格」:多件先删后加,单件用 createOrder 覆盖 +- 对话中直接给出收货地址 → `saveAddress`(保存后接力进结算页确认支付) +- 只表达『改/换收货地址』意图但未给出新地址 → **先追问用户新的收货人+手机号+详细地址,禁止直接调用 `saveAddress`**、禁止沿用旧地址占位 +- 歧义短语("那个")→ 先反问澄清,禁止猜测 diff --git a/miniprogram/skills/drink-skill/apis/addToOrder.js b/miniprogram/skills/drink-skill/apis/addToOrder.js new file mode 100644 index 00000000..2655b498 --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/addToOrder.js @@ -0,0 +1,95 @@ +const { getActiveOrder, getAddress, saveOrder, setActiveOrderId, recalcOrderTotal } = require('../utils/storage.js') +const { buildOrderItem } = require('../utils/sku.js') +const { genOrderId } = require('../utils/id.js') + +async function addToOrder({ drinkId, specs } = {}) { + try { + if (!drinkId) { + return { + isError: true, + content: [{ type: 'text', text: '缺少 drinkId。禁止编造,应先调用 selectDrink 获取有效 drinkId。' }] + } + } + + const built = buildOrderItem(drinkId, specs) + if (!built.valid) { + const isSpecErr = /不支持|不合法|可选/.test(built.error || '') + const text = isSpecErr + ? `规格不合法:${built.error}。禁止再次使用相同无效值重试。正确出口:引导用户通过饮品详情卡片上的"选规格"按钮打开半屏重新选择。` + : `未找到 drinkId=${drinkId} 的饮品。禁止编造 ID,应先调用 selectDrink 获取有效 drinkId。` + return { isError: true, content: [{ type: 'text', text }] } + } + + const item = built.item + + let order = getActiveOrder() + const isNewOrder = !order + if (isNewOrder) { + order = { + orderId: genOrderId(), + items: [], + itemCount: 0, + totalPrice: 0, + status: 'pending', + createTime: new Date().toISOString() + } + } + + order.items = Array.isArray(order.items) ? order.items : [] + order.items.push(item) + recalcOrderTotal(order) + + const address = order.address || getAddress() + if (address) order.address = address + const needAddress = !address + order.status = needAddress ? 'pending' : 'confirmed' + saveOrder(order) + if (isNewOrder) setActiveOrderId(order.orderId) + + const structuredContent = { + orderId: order.orderId, + items: order.items.map(it => ({ itemId: it.itemId, drinkName: it.drinkName, specText: it.specText, totalPrice: it.totalPrice })), + itemCount: order.itemCount, + totalPrice: order.totalPrice, + needAddress, + status: order.status + } + + const handoffPayload = { + orderId: order.orderId, + items: order.items, + itemCount: order.itemCount, + totalPrice: order.totalPrice, + address: address || null, + needAddress, + status: order.status + } + + const actionText = isNewOrder + ? `已为用户新建订单并加入「${item.drinkName} ${item.specText}」,当前共 ${order.itemCount} 件,合计 ¥${order.totalPrice}。请用一句简短话术引导用户点击下方小程序卡片进入结算页查看已选商品并${needAddress ? '补充地址后' : ''}下单。禁止以纯文本列出订单详情。` + : `已把「${item.drinkName} ${item.specText}」加入当前订单,现共 ${order.itemCount} 件,合计 ¥${order.totalPrice}。请用一句简短话术引导用户点击下方小程序卡片进入结算页查看已选商品并${needAddress ? '补充地址后' : ''}下单。禁止以纯文本列出订单详情。` + + return { + isError: false, + content: [{ type: 'text', text: actionText }], + structuredContent, + handoff: { + query: `orderId=${order.orderId}`, + payload: handoffPayload + }, + _meta: { + items: order.items, + address: address || null, + pendingOrderId: needAddress ? order.orderId : undefined + } + } + } catch (err) { + console.error('[addToOrder] error', err) + return { + isError: true, + content: [{ type: 'text', text: `追加商品失败:${err.message || '未知错误'}。` }] + } + } +} + +module.exports = addToOrder diff --git a/miniprogram/skills/drink-skill/apis/cancelOrder.js b/miniprogram/skills/drink-skill/apis/cancelOrder.js new file mode 100644 index 00000000..b0cce7ec --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/cancelOrder.js @@ -0,0 +1,48 @@ +const { getActiveOrder, saveOrder, clearActiveOrderId } = require('../utils/storage.js') + +async function cancelOrder() { + try { + const order = getActiveOrder() + if (!order || !Array.isArray(order.items) || !order.items.length) { + return { + isError: true, + content: [{ type: 'text', text: '当前没有进行中的订单,无需取消。请直接告知用户暂无可取消的订单,禁止重试本接口。' }] + } + } + + const canceledCount = order.items.length + order.items = [] + order.itemCount = 0 + order.totalPrice = 0 + order.status = 'canceled' + order.cancelTime = new Date().toISOString() + saveOrder(order) + clearActiveOrderId() + + return { + isError: false, + content: [{ + type: 'text', + text: `已取消当前订单(原有 ${canceledCount} 件商品已全部清空)。请用一句简短话术告知用户订单已取消,并引导用户点击下方小程序卡片回首页重新挑选下单。禁止以纯文本列出订单详情。` + }], + structuredContent: { + success: true, + orderId: order.orderId, + canceledCount, + status: 'canceled' + }, + handoff: { + query: '' + }, + _meta: {} + } + } catch (err) { + console.error('[cancelOrder] error', err) + return { + isError: true, + content: [{ type: 'text', text: `取消订单失败:${err.message || '未知错误'}。` }] + } + } +} + +module.exports = cancelOrder diff --git a/miniprogram/skills/drink-skill/apis/createOrder.js b/miniprogram/skills/drink-skill/apis/createOrder.js new file mode 100644 index 00000000..a3cd0458 --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/createOrder.js @@ -0,0 +1,89 @@ +const { getAddress, saveOrder, setActiveOrderId } = require('../utils/storage.js') +const { buildOrderItem } = require('../utils/sku.js') +const { genOrderId } = require('../utils/id.js') + +async function createOrder({ drinkId, specs } = {}) { + try { + if (!drinkId) { + return { + isError: true, + content: [{ type: 'text', text: '缺少 drinkId。禁止编造,应先调用 selectDrink。' }] + } + } + + const built = buildOrderItem(drinkId, specs) + if (!built.valid) { + const isSpecErr = /不支持|不合法|可选/.test(built.error || '') + const text = isSpecErr + ? `规格不合法:${built.error}。禁止再次使用相同无效值重试。正确出口:引导用户通过饮品详情卡片上的"选规格"按钮打开半屏重新选择。` + : `未找到 drinkId=${drinkId} 的饮品。禁止编造 ID,应先调用 selectDrink 获取有效 drinkId。` + return { isError: true, content: [{ type: 'text', text }] } + } + + const item = built.item + const orderId = genOrderId() + const order = { + orderId, + items: [item], + itemCount: 1, + totalPrice: item.totalPrice, + status: 'pending', + createTime: new Date().toISOString() + } + + const address = getAddress() + if (address) order.address = address + saveOrder(order) + setActiveOrderId(orderId) + + const needAddress = !address + order.status = needAddress ? 'pending' : 'confirmed' + saveOrder(order) + + const structuredContent = { + orderId, + items: order.items.map(it => ({ itemId: it.itemId, drinkName: it.drinkName, specText: it.specText, totalPrice: it.totalPrice })), + itemCount: order.itemCount, + totalPrice: order.totalPrice, + needAddress, + status: order.status + } + + const handoffPayload = { + orderId, + items: order.items, + itemCount: order.itemCount, + totalPrice: order.totalPrice, + address: address || null, + needAddress, + status: order.status + } + + const actionText = needAddress + ? `已为用户新建订单(${item.drinkName} ${item.specText},¥${item.totalPrice}),尚未填写收货地址。请用一句简短话术引导用户点击下方小程序卡片进入结算页补充地址并下单。禁止以纯文本列出订单详情。` + : `已为用户新建并确认订单(${item.drinkName} ${item.specText},¥${item.totalPrice})。请用一句简短话术引导用户点击下方小程序卡片进入结算页确认并支付。禁止以纯文本列出订单详情。【地址处理规则】收货地址由用户在结算页内自行选择填写,本轮不需要保存地址。即使用户提到了配送地点(如『送到公司』『送到家』),只要没有同时给出完整的收货人姓名和手机号,就不要保存地址,也禁止把收货人姓名/手机号编造成占位值去保存;直接引导用户点击卡片进入结算页填写收货信息即可。` + + return { + isError: false, + content: [{ type: 'text', text: actionText }], + structuredContent, + handoff: { + query: `orderId=${orderId}`, + payload: handoffPayload + }, + _meta: { + items: order.items, + address: address || null, + pendingOrderId: needAddress ? orderId : undefined + } + } + } catch (err) { + console.error('[createOrder] error', err) + return { + isError: true, + content: [{ type: 'text', text: `新建订单失败:${err.message || '未知错误'}。` }] + } + } +} + +module.exports = createOrder diff --git a/miniprogram/skills/drink-skill/apis/removeOrderItem.js b/miniprogram/skills/drink-skill/apis/removeOrderItem.js new file mode 100644 index 00000000..ad5e3cf5 --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/removeOrderItem.js @@ -0,0 +1,97 @@ +const { getActiveOrder, getAddress, saveOrder, recalcOrderTotal } = require('../utils/storage.js') + +async function removeOrderItem({ itemId } = {}) { + try { + const order = getActiveOrder() + if (!order || !Array.isArray(order.items) || !order.items.length) { + return { + isError: true, + content: [{ type: 'text', text: '当前没有进行中的订单,或订单内没有商品,无法删除。请直接告知用户暂无可删除的商品,禁止重试本接口。' }] + } + } + + if (order.items.length <= 1) { + return { + isError: true, + content: [{ + type: 'text', + text: '订单中仅剩最后一件商品,不能删除。请直接告知用户「订单至少需保留一件商品,无法删除最后一件;如需清空整单可说『取消订单』」,禁止重试本接口。' + }] + } + } + + if (!itemId) { + return { + isError: true, + content: [{ + type: 'text', + text: `缺少 itemId,无法定位要删除的商品。当前订单商品:${order.items.map(it => `${it.itemId}=${it.drinkName}(${it.specText})`).join('、')}。请根据用户所指从中选定对应 itemId 后再调用,禁止编造 itemId。` + }] + } + } + + const idx = order.items.findIndex(it => it.itemId === itemId) + if (idx < 0) { + return { + isError: true, + content: [{ + type: 'text', + text: `未在订单中找到 itemId=${itemId} 的商品。当前订单商品:${order.items.map(it => `${it.itemId}=${it.drinkName}(${it.specText})`).join('、')}。请从中重新选定正确 itemId,禁止编造。` + }] + } + } + + const removed = order.items.splice(idx, 1)[0] + recalcOrderTotal(order) + + const address = order.address || getAddress() + if (address) order.address = address + const needAddress = !address + order.status = needAddress ? 'pending' : 'confirmed' + saveOrder(order) + + const structuredContent = { + orderId: order.orderId, + items: order.items.map(it => ({ itemId: it.itemId, drinkName: it.drinkName, specText: it.specText, totalPrice: it.totalPrice })), + itemCount: order.itemCount, + totalPrice: order.totalPrice, + needAddress, + status: order.status + } + + const handoffPayload = { + orderId: order.orderId, + items: order.items, + itemCount: order.itemCount, + totalPrice: order.totalPrice, + address: address || null, + needAddress, + status: order.status + } + + return { + isError: false, + content: [{ + type: 'text', + text: `已从订单中删除「${removed.drinkName} ${removed.specText}」,现共 ${order.itemCount} 件,合计 ¥${order.totalPrice}。请用一句简短话术引导用户点击下方小程序卡片进入结算页查看剩余商品并下单。禁止以纯文本列出订单详情。` + }], + structuredContent, + handoff: { + query: `orderId=${order.orderId}`, + payload: handoffPayload + }, + _meta: { + items: order.items, + address: address || null + } + } + } catch (err) { + console.error('[removeOrderItem] error', err) + return { + isError: true, + content: [{ type: 'text', text: `删除商品失败:${err.message || '未知错误'}。` }] + } + } +} + +module.exports = removeOrderItem diff --git a/miniprogram/skills/drink-skill/apis/saveAddress.js b/miniprogram/skills/drink-skill/apis/saveAddress.js new file mode 100644 index 00000000..84548ef1 --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/saveAddress.js @@ -0,0 +1,86 @@ +const { setAddress, getActiveOrder, saveOrder, recalcOrderTotal } = require('../utils/storage.js') + +function isPhone(p) { + return typeof p === 'string' && /^1\d{10}$/.test(p) +} + +async function saveAddress({ name, phone, detail } = {}) { + try { + if (!name || !String(name).trim()) { + return { isError: true, content: [{ type: 'text', text: '缺少收货人姓名。请反问用户补充。' }] } + } + if (!isPhone(phone)) { + return { isError: true, content: [{ type: 'text', text: '手机号格式不正确,需为 11 位数字。请反问用户确认手机号。' }] } + } + if (!detail || !String(detail).trim()) { + return { isError: true, content: [{ type: 'text', text: '缺少详细地址。请反问用户补充。' }] } + } + + const address = { + name: String(name).trim(), + phone: String(phone).trim(), + detail: String(detail).trim() + } + setAddress(address) + + const order = getActiveOrder() + if (order) { + order.address = address + order.status = 'confirmed' + recalcOrderTotal(order) + saveOrder(order) + + const structuredContent = { + success: true, + orderId: order.orderId, + items: order.items.map(it => ({ itemId: it.itemId, drinkName: it.drinkName, specText: it.specText, totalPrice: it.totalPrice })), + itemCount: order.itemCount, + totalPrice: order.totalPrice, + needAddress: false, + status: 'confirmed' + } + + const handoffPayload = { + orderId: order.orderId, + items: order.items, + itemCount: order.itemCount, + totalPrice: order.totalPrice, + address, + needAddress: false, + status: 'confirmed' + } + + return { + isError: false, + content: [{ + type: 'text', + text: `地址已保存,订单(共 ${order.itemCount} 件,合计 ¥${order.totalPrice})已更新为 confirmed。请用一句简短话术引导用户点击下方小程序卡片进入结算页确认并支付。禁止以纯文本列出订单详情。` + }], + structuredContent, + handoff: { + query: `orderId=${order.orderId}`, + payload: handoffPayload + }, + _meta: { + items: order.items, + address + } + } + } + + return { + isError: false, + content: [{ type: 'text', text: '地址已保存成功。' }], + structuredContent: { success: true }, + _meta: {} + } + } catch (err) { + console.error('[saveAddress] error', err) + return { + isError: true, + content: [{ type: 'text', text: `保存地址失败:${err.message || '未知错误'}。` }] + } + } +} + +module.exports = saveAddress diff --git a/miniprogram/skills/drink-skill/apis/searchDrinks.js b/miniprogram/skills/drink-skill/apis/searchDrinks.js new file mode 100644 index 00000000..ba6acd95 --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/searchDrinks.js @@ -0,0 +1,80 @@ +const { getCatalog } = require('../utils/storage.js') + +async function searchDrinks({ keyword } = {}) { + try { + const hasKw = !!(keyword && typeof keyword === 'string' && keyword.trim()) + const catalog = getCatalog() + + const matched = hasKw + ? (() => { + const kw = keyword.trim().toLowerCase() + return catalog.filter(d => + d.name.toLowerCase().includes(kw) || + d.categoryName.toLowerCase().includes(kw) || + (d.description || '').toLowerCase().includes(kw) + ) + })() + : catalog + + if (hasKw && !matched.length) { + return { + isError: true, + content: [{ + type: 'text', + text: `未在商品库中匹配到包含「${keyword}」的饮品记录。禁止编造商品名再次调用本接口。正确出口:引导用户换个关键词(如拿铁、美式、奶咖、水果茶)重新搜索,或不带关键词调用本接口浏览全部饮品。` + }] + } + } + + const picked = matched + + const items = picked.map(d => ({ + drinkId: d.id, + name: d.name, + price: d.price, + categoryName: d.categoryName, + description: d.description, + pagePath: 'packageWeStoreCoffee/pages/sku-picker/sku-picker?drinkId='+ d.id + })) + + const viewItems = picked.map(d => ({ + drinkId: d.id, + name: d.name, + price: d.price, + categoryName: d.categoryName, + description: d.description, + imageUrl: d.imageUrl + })) + + return { + isError: false, + content: [{ + type: 'text', + text: hasKw + ? `已搜索到 ${matched.length} 款匹配「${keyword}」的饮品。本次检索已完成,请直接用一句简短话术引导用户点击下方小程序卡片,在结果列表中自行挑选。【必须遵守】本轮禁止再次调用检索、也禁止自行编写代码对本批饮品逐条比对筛选。若用户指定的具体饮品名与结果中任何一款都不完全相同(例如用户想要名字恰好叫「拿铁」的,但结果里只有「焦糖蜂窝拿铁」这类带前缀的),说明该店没有完全同名的商品,此时必须直接展示本批结果卡片,并用一句话向用户说明没有完全同名的商品、请其从相近结果中挑选,严禁反复重新搜索。禁止以纯文本列出饮品详情。` + : `已为用户列出全部 ${matched.length} 款饮品。请用一句简短话术引导用户点击下方小程序卡片进入完整列表挑选。禁止以纯文本列出饮品详情。` + }], + structuredContent: { + items, + total: matched.length, + hasMore: matched.length > picked.length, + keyword: hasKw ? keyword : null + }, + handoff: { + query: hasKw ? `keyword=${encodeURIComponent(keyword)}` : '', + payload: { items: viewItems, keyword: hasKw ? keyword : null } + }, + _meta: { + viewItems + } + } + } catch (err) { + console.error('[searchDrinks] error', err) + return { + isError: true, + content: [{ type: 'text', text: `搜索失败:${err.message || '未知错误'}。请引导用户稍后重试。` }] + } + } +} + +module.exports = searchDrinks diff --git a/miniprogram/skills/drink-skill/apis/selectDrink.js b/miniprogram/skills/drink-skill/apis/selectDrink.js new file mode 100644 index 00000000..85c7f7a7 --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/selectDrink.js @@ -0,0 +1,73 @@ +const { findDrink } = require('../utils/storage.js') + +async function selectDrink({ drinkId } = {}) { + try { + if (!drinkId) { + return { + isError: true, + content: [{ type: 'text', text: '缺少 drinkId。禁止编造 ID 再次调用本接口。正确出口:先调用 searchDrinks 获取可用 drinkId。' }] + } + } + const drink = findDrink(drinkId) + if (!drink) { + return { + isError: true, + content: [{ + type: 'text', + text: `未在商品库中找到 drinkId=${drinkId} 的饮品记录。禁止编造其他 ID 再次调用本接口,禁止从用户自然语言推断 ID。正确出口:调用 searchDrinks 获取有效的 drinkId。` + }] + } + } + + try { wx.setStorageSync('current_sku_drink_id', drink.id) } catch (e) {} + + const specOptions = {} + drink.skuSchema.dimensions.forEach(d => { + specOptions[d.key] = { + label: d.label, + multiple: !!d.multiple, + options: d.options.map(o => ({ value: o.value, label: o.label, extraPrice: o.extraPrice || 0 })) + } + }) + + return { + isError: false, + content: [{ + type: 'text', + text: `已加载饮品「${drink.name}」详情(基础价 ¥${drink.price})。请用一句简短话术引导用户点击下方小程序卡片进入饮品详情页,在页面内选规格并下单。禁止以纯文本列出商品详情。` + }], + structuredContent: { + drinkId: drink.id, + name: drink.name, + price: drink.price, + description: drink.description, + categoryName: drink.categoryName, + specOptions + }, + handoff: { + query: `drinkId=${drink.id}`, + payload: { + drinkId: drink.id, + name: drink.name, + price: drink.price, + description: drink.description, + categoryName: drink.categoryName, + imageUrl: drink.imageUrl, + skuSchema: drink.skuSchema + } + }, + _meta: { + imageUrl: drink.imageUrl, + skuSchema: drink.skuSchema + } + } + } catch (err) { + console.error('[selectDrink] error', err) + return { + isError: true, + content: [{ type: 'text', text: `选择饮品失败:${err.message || '未知错误'}。` }] + } + } +} + +module.exports = selectDrink diff --git a/miniprogram/skills/drink-skill/apis/viewOrder.js b/miniprogram/skills/drink-skill/apis/viewOrder.js new file mode 100644 index 00000000..7d92b383 --- /dev/null +++ b/miniprogram/skills/drink-skill/apis/viewOrder.js @@ -0,0 +1,60 @@ +const { getActiveOrder, getAddress } = require('../utils/storage.js') + +async function viewOrder() { + try { + const order = getActiveOrder() + if (!order || !Array.isArray(order.items) || !order.items.length) { + return { + isError: true, + content: [{ type: 'text', text: '当前没有进行中的订单可查看。请直接告知用户暂无进行中的订单,可先点单后再查看,禁止重试本接口。' }] + } + } + + const address = order.address || getAddress() + const needAddress = !address + + const structuredContent = { + orderId: order.orderId, + items: order.items.map(it => ({ itemId: it.itemId, drinkName: it.drinkName, specText: it.specText, totalPrice: it.totalPrice })), + itemCount: order.itemCount, + totalPrice: order.totalPrice, + needAddress, + status: order.status + } + + const handoffPayload = { + orderId: order.orderId, + items: order.items, + itemCount: order.itemCount, + totalPrice: order.totalPrice, + address: address || null, + needAddress, + status: order.status + } + + return { + isError: false, + content: [{ + type: 'text', + text: `已为用户调出当前订单(共 ${order.itemCount} 件,合计 ¥${order.totalPrice})。请用一句简短话术引导用户点击下方小程序卡片进入结算页查看订单详情${needAddress ? '并补充收货地址' : ''}。禁止以纯文本列出订单详情。` + }], + structuredContent, + handoff: { + query: `orderId=${order.orderId}`, + payload: handoffPayload + }, + _meta: { + items: order.items, + address: address || null + } + } + } catch (err) { + console.error('[viewOrder] error', err) + return { + isError: true, + content: [{ type: 'text', text: `查看订单失败:${err.message || '未知错误'}。` }] + } + } +} + +module.exports = viewOrder diff --git a/miniprogram/skills/drink-skill/components/drink-detail-card/index.js b/miniprogram/skills/drink-skill/components/drink-detail-card/index.js new file mode 100644 index 00000000..75718d5b --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-detail-card/index.js @@ -0,0 +1,66 @@ +const DEFAULT_SINGLE_SPEC = { temperature: 'ice', sugar: 'normal', cupSize: 'medium' } + +Component({ + data: { + drinkId: 0, + name: '', + price: 0, + description: '', + categoryName: '', + imageUrl: '', + dimsLabel: '' + }, + lifetimes: { + created() { + this._modelCtx = wx.modelContext.getContext(this) + this._viewCtx = wx.modelContext.getViewContext(this) + const { NotificationType } = wx.modelContext + this._modelCtx.on(NotificationType.Result, (data) => { + const result = data && data.result ? data.result : {} + const sc = result.structuredContent || {} + const meta = result._meta || {} + const schema = meta.skuSchema || { dimensions: [] } + const dims = (schema.dimensions || []).map(d => d.label).join(' / ') + this.setData({ + drinkId: sc.drinkId, + name: sc.name, + price: sc.price, + description: sc.description || '', + categoryName: sc.categoryName || '', + imageUrl: meta.imageUrl || '', + dimsLabel: dims + }) + this._skuSchema = schema + if (sc.drinkId) { + this._viewCtx.setRelatedPage({ query: `drinkId=${sc.drinkId}` }) + } + }) + } + }, + methods: { + onTapOrder() { + if (!this.data.drinkId) return + const specs = {} + const dims = (this._skuSchema && this._skuSchema.dimensions) || [] + dims.forEach(d => { + if (d.multiple) { + specs[d.key] = [] + } else { + specs[d.key] = DEFAULT_SINGLE_SPEC[d.key] || d.options[0].value + } + }) + this._modelCtx.sendFollowUpMessage({ + content: [ + { type: 'text', text: '直接下单' }, + { type: 'api/call', data: { name: 'createOrder', arguments: { drinkId: this.data.drinkId, specs } } } + ] + }) + }, + onTapModifySku() { + if (!this.data.drinkId) return + this._viewCtx.openDetailPage({ + url: `/packageWeStoreCoffee/pages/sku-picker/sku-picker?drinkId=${this.data.drinkId}` + }) + } + } +}) diff --git a/miniprogram/skills/drink-skill/components/drink-detail-card/index.json b/miniprogram/skills/drink-skill/components/drink-detail-card/index.json new file mode 100644 index 00000000..a89ef4db --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-detail-card/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/skills/drink-skill/components/drink-detail-card/index.wxml b/miniprogram/skills/drink-skill/components/drink-detail-card/index.wxml new file mode 100644 index 00000000..5c270e16 --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-detail-card/index.wxml @@ -0,0 +1,18 @@ + + + + {{categoryName}} + {{name}} + {{description}} + + ¥{{price}} + 起 + + 可选规格:{{dimsLabel}} + + + 直接下单 + + + + diff --git a/miniprogram/skills/drink-skill/components/drink-detail-card/index.wxss b/miniprogram/skills/drink-skill/components/drink-detail-card/index.wxss new file mode 100644 index 00000000..e5f1079f --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-detail-card/index.wxss @@ -0,0 +1,149 @@ +.dd-card { + background: #FFFFFF; + border-radius: 28rpx; + padding: 24rpx; + box-shadow: 0 6rpx 24rpx rgba(92, 58, 33, 0.08); + display: flex; + flex-direction: row; + align-items: stretch; + width: 100%; + box-sizing: border-box; +} + +.dd-img { + width: 200rpx; + height: 200rpx; + border-radius: 20rpx; + background-color: #F5EFE6; + flex-shrink: 0; +} + +.dd-info { + flex: 1; + margin-left: 24rpx; + display: flex; + flex-direction: column; + min-width: 0; +} + +.dd-cat { + font-size: 22rpx; + color: #8B5E3C; + background: #FFF8F0; + padding: 4rpx 14rpx; + border-radius: 8rpx; + align-self: flex-start; +} + +.dd-name { + font-size: 34rpx; + font-weight: 700; + color: #2A1810; + margin-top: 10rpx; +} + +.dd-desc { + font-size: 22rpx; + color: #6B5544; + margin-top: 6rpx; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dd-price-row { + display: flex; + align-items: baseline; + margin-top: 12rpx; +} + +.dd-price { + font-size: 36rpx; + font-weight: 700; + color: #5C3A21; +} + +.dd-unit { + font-size: 22rpx; + color: #A89180; + margin-left: 6rpx; +} + +.dd-dims { + font-size: 20rpx; + color: #A89180; + margin-top: 8rpx; +} + +.dd-actions { + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; +} + +.dd-btn { + width: 100%; + padding: 18rpx 0; + border-radius: 999rpx; + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + text-align: center; +} + +.dd-btn-hover { + opacity: 0.85; +} + +.dd-btn-text { + color: #FFFFFF; + font-size: 26rpx; + font-weight: 600; + letter-spacing: 1rpx; +} + +.dd-modify { + margin-top: 12rpx; + font-size: 22rpx; + color: #8B5E3C; + text-decoration: underline; +} + +.dd-modify-hover { + opacity: 0.6; +} + +@media (prefers-color-scheme: dark) { + .dd-card { + background: #1E1E1E; + box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.3); + } + .dd-img { + background-color: #2A2A2A; + } + .dd-cat { + color: #D4A574; + background: #2A2218; + } + .dd-name { + color: #E8E0D8; + } + .dd-desc { + color: #9E8E7E; + } + .dd-price { + color: #D4A574; + } + .dd-unit { + color: #7A6E62; + } + .dd-dims { + color: #7A6E62; + } + .dd-btn { + background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); + } + .dd-modify { + color: #D4A574; + } +} diff --git a/miniprogram/skills/drink-skill/components/drink-not-found-card/index.js b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.js new file mode 100644 index 00000000..463cb12f --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.js @@ -0,0 +1,21 @@ +Component({ + data: { + keyword: '' + }, + lifetimes: { + created() { + this._modelCtx = wx.modelContext.getContext(this) + this._viewCtx = wx.modelContext.getViewContext(this) + const { NotificationType } = wx.modelContext + this._modelCtx.on(NotificationType.Input, (data) => { + const input = (data && data.input) || {} + this.setData({ keyword: input.keyword || '' }) + }) + } + }, + methods: { + onTapBrowse() { + this._viewCtx.openDetailPage({ url: '/packageWeStoreCoffee/pages/home/home' }) + } + } +}) diff --git a/miniprogram/skills/drink-skill/components/drink-not-found-card/index.json b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.json new file mode 100644 index 00000000..a89ef4db --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxml b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxml new file mode 100644 index 00000000..033c47c8 --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxml @@ -0,0 +1,6 @@ + + 🍵 + 没找到匹配饮品 + 关键词:"{{keyword}}" + 浏览全部饮品 + diff --git a/miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxss b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxss new file mode 100644 index 00000000..0a5fb878 --- /dev/null +++ b/miniprogram/skills/drink-skill/components/drink-not-found-card/index.wxss @@ -0,0 +1,59 @@ +.nf-card { + background: #FFFFFF; + border-radius: 28rpx; + padding: 40rpx 28rpx; + box-shadow: 0 6rpx 24rpx rgba(92, 58, 33, 0.08); + text-align: center; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; +} + +.nf-icon { + font-size: 64rpx; + margin-bottom: 10rpx; +} + +.nf-title { + font-size: 30rpx; + font-weight: 600; + color: #2A1810; +} + +.nf-sub { + font-size: 22rpx; + color: #A89180; + margin-top: 8rpx; +} + +.nf-btn { + margin-top: 20rpx; + padding: 18rpx 40rpx; + border-radius: 999rpx; + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + color: #FFFFFF; + font-size: 26rpx; + font-weight: 600; +} + +.btn-hover { + opacity: 0.85; +} + +@media (prefers-color-scheme: dark) { + .nf-card { + background: #1E1E1E; + box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.3); + } + .nf-title { + color: #E8E0D8; + } + .nf-sub { + color: #7A6E62; + } + .nf-btn { + background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); + } +} diff --git a/miniprogram/skills/drink-skill/components/order-confirm-card/index.js b/miniprogram/skills/drink-skill/components/order-confirm-card/index.js new file mode 100644 index 00000000..7d6861ae --- /dev/null +++ b/miniprogram/skills/drink-skill/components/order-confirm-card/index.js @@ -0,0 +1,48 @@ +Component({ + data: { + orderId: '', + drinkName: '', + specText: '', + totalPrice: 0, + basePrice: 0, + extraPrice: 0, + imageUrl: '', + address: null, + hasAddress: false, + hint: '' + }, + lifetimes: { + created() { + this._modelCtx = wx.modelContext.getContext(this) + this._viewCtx = wx.modelContext.getViewContext(this) + const { NotificationType } = wx.modelContext + this._modelCtx.on(NotificationType.Result, (data) => { + const result = data && data.result ? data.result : {} + const sc = result.structuredContent || {} + const meta = result._meta || {} + const addr = meta.address || null + this.setData({ + orderId: sc.orderId || '', + drinkName: sc.drinkName || '', + specText: sc.specText || '', + totalPrice: sc.totalPrice || 0, + basePrice: meta.basePrice || 0, + extraPrice: meta.extraPrice || 0, + imageUrl: meta.imageUrl || '', + address: addr, + hasAddress: !!addr, + hint: sc.needAddress ? '请先添加收货地址' : '' + }) + if (sc.orderId) { + this._viewCtx.setRelatedPage({ query: `orderId=${sc.orderId}` }) + } + }) + } + }, + methods: { + onTapPay() { + }, + onTapAddress() { + } + } +}) diff --git a/miniprogram/skills/drink-skill/components/order-confirm-card/index.json b/miniprogram/skills/drink-skill/components/order-confirm-card/index.json new file mode 100644 index 00000000..a89ef4db --- /dev/null +++ b/miniprogram/skills/drink-skill/components/order-confirm-card/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/skills/drink-skill/components/order-confirm-card/index.wxml b/miniprogram/skills/drink-skill/components/order-confirm-card/index.wxml new file mode 100644 index 00000000..3c4e3863 --- /dev/null +++ b/miniprogram/skills/drink-skill/components/order-confirm-card/index.wxml @@ -0,0 +1,46 @@ + + + 订单确认 + #{{orderId}} + + + + + + + {{address.name}} + {{address.phone}} + + {{address.detail}} + + 修改 + + + + 添加收货地址 + 请先添加收货地址以便下单 + + 去添加 + + + + + + + {{drinkName}} + {{specText}} + ¥{{totalPrice}} × 1 + + + + + + + 合计 + ¥{{totalPrice}} + + + + 确认下单 ¥{{totalPrice}} + + diff --git a/miniprogram/skills/drink-skill/components/order-confirm-card/index.wxss b/miniprogram/skills/drink-skill/components/order-confirm-card/index.wxss new file mode 100644 index 00000000..ee5c272c --- /dev/null +++ b/miniprogram/skills/drink-skill/components/order-confirm-card/index.wxss @@ -0,0 +1,249 @@ +.oc-card { + background: #FFFFFF; + border-radius: 28rpx; + padding: 20rpx; + box-shadow: 0 6rpx 24rpx rgba(92, 58, 33, 0.08); + box-sizing: border-box; + width: 100%; +} + +.oc-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 12rpx; +} + +.oc-title { + font-size: 30rpx; + font-weight: 700; + color: #2A1810; +} + +.oc-order-id { + font-size: 20rpx; + color: #A89180; + font-family: monospace; +} + +.oc-addr { + display: flex; + align-items: center; + padding: 12rpx; + background: #FFF8F0; + border-radius: 12rpx; + margin-bottom: 10rpx; +} + +.oc-addr-empty { + background: #FFF4E6; + border: 2rpx dashed #D9B78C; +} + +.oc-addr-empty-title { + font-size: 26rpx; + color: #5C3A21; + font-weight: 600; +} + +.oc-addr-empty-sub { + font-size: 22rpx; + color: #A89180; + margin-top: 4rpx; +} + +.row-hover { + opacity: 0.85; +} + +.oc-addr-body { + flex: 1; + min-width: 0; +} + +.oc-addr-top { + display: flex; + align-items: baseline; +} + +.oc-addr-name { + font-size: 26rpx; + color: #2A1810; + font-weight: 600; +} + +.oc-addr-phone { + font-size: 22rpx; + color: #6B5544; + margin-left: 14rpx; +} + +.oc-addr-detail { + font-size: 20rpx; + color: #6B5544; + margin-top: 2rpx; + line-height: 1.3; + overflow: hidden; + -wx-line-clamp: 1; +} + +.oc-edit-text { + font-size: 22rpx; + color: #8B5E3C; + align-self: center; + margin-left: 10rpx; +} + +.oc-item { + display: flex; + align-items: center; + padding: 8rpx 0; +} + +.oc-img { + width: 80rpx; + height: 80rpx; + border-radius: 12rpx; + background-color: #F5EFE6; + flex-shrink: 0; +} + +.oc-item-info { + flex: 1; + margin-left: 16rpx; + min-width: 0; +} + +.oc-item-name { + font-size: 28rpx; + font-weight: 600; + color: #2A1810; +} + +.oc-item-spec { + font-size: 22rpx; + color: #6B5544; + margin-top: 4rpx; +} + +.oc-item-price { + font-size: 24rpx; + color: #5C3A21; + margin-top: 6rpx; +} + +.oc-divider { + height: 2rpx; + background: #EDE3D4; + margin: 10rpx 0; +} + +.oc-summary { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 12rpx; +} + +.oc-summary-label { + font-size: 26rpx; + color: #6B5544; +} + +.oc-summary-price { + font-size: 38rpx; + font-weight: 800; + color: #5C3A21; +} + +.oc-pay-btn { + padding: 16rpx 0; + border-radius: 999rpx; + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + text-align: center; +} + +.btn-hover { + opacity: 0.85; +} + +.oc-pay-text { + color: #FFFFFF; + font-size: 28rpx; + font-weight: 700; + letter-spacing: 2rpx; +} + +.oc-hint { + font-size: 24rpx; + color: #6B5544; + line-height: 1.5; + padding: 20rpx 8rpx; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + .oc-card { + background: #1E1E1E; + box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.3); + } + .oc-title { + color: #E8E0D8; + } + .oc-order-id { + color: #7A6E62; + } + .oc-addr { + background: #2A2218; + } + .oc-addr-empty { + background: #2A2218; + border-color: #4E3E2E; + } + .oc-addr-empty-title { + color: #D4A574; + } + .oc-addr-empty-sub { + color: #7A6E62; + } + .oc-addr-name { + color: #E8E0D8; + } + .oc-addr-phone { + color: #9E8E7E; + } + .oc-addr-detail { + color: #9E8E7E; + } + .oc-edit-text { + color: #D4A574; + } + .oc-img { + background-color: #2A2A2A; + } + .oc-item-name { + color: #E8E0D8; + } + .oc-item-spec { + color: #9E8E7E; + } + .oc-item-price { + color: #D4A574; + } + .oc-divider { + background: #3E3228; + } + .oc-summary-label { + color: #9E8E7E; + } + .oc-summary-price { + color: #D4A574; + } + .oc-pay-btn { + background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); + } + .oc-hint { + color: #9E8E7E; + } +} + diff --git a/miniprogram/skills/drink-skill/components/recommended-drinks/index.js b/miniprogram/skills/drink-skill/components/recommended-drinks/index.js new file mode 100644 index 00000000..3d34c44a --- /dev/null +++ b/miniprogram/skills/drink-skill/components/recommended-drinks/index.js @@ -0,0 +1,47 @@ +Component({ + data: { + title: '为你推荐', + items: [], + total: 0, + hasMore: false + }, + lifetimes: { + created() { + this._modelCtx = wx.modelContext.getContext(this) + this._viewCtx = wx.modelContext.getViewContext(this) + const { NotificationType } = wx.modelContext + this._modelCtx.on(NotificationType.Result, (data) => { + const result = data && data.result ? data.result : {} + const sc = result.structuredContent || {} + const meta = result._meta || {} + const viewItems = meta.viewItems || sc.items || [] + this.setData({ + items: viewItems.slice(0, 3), + total: sc.total || viewItems.length, + hasMore: sc.hasMore || (sc.total && sc.total > 3), + title: sc.keyword ? `"${sc.keyword}" 搜索结果` : '为你推荐' + }) + if (sc.keyword) { + this._viewCtx.setRelatedPage({ query: `keyword=${encodeURIComponent(sc.keyword)}` }) + } + }) + } + }, + methods: { + onTapItem(e) { + const item = e.currentTarget.dataset.item + if (!item) return + this._modelCtx.sendFollowUpMessage({ + content: [ + { type: 'text', text: `选择${item.name}` }, + { type: 'api/call', data: { name: 'selectDrink', arguments: { drinkId: item.drinkId } } } + ] + }) + }, + onTapMore() { + this._viewCtx.openDetailPage({ + url: '/packageWeStoreCoffee/pages/home/home' + }) + } + } +}) diff --git a/miniprogram/skills/drink-skill/components/recommended-drinks/index.json b/miniprogram/skills/drink-skill/components/recommended-drinks/index.json new file mode 100644 index 00000000..a89ef4db --- /dev/null +++ b/miniprogram/skills/drink-skill/components/recommended-drinks/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/skills/drink-skill/components/recommended-drinks/index.wxml b/miniprogram/skills/drink-skill/components/recommended-drinks/index.wxml new file mode 100644 index 00000000..35f7ecac --- /dev/null +++ b/miniprogram/skills/drink-skill/components/recommended-drinks/index.wxml @@ -0,0 +1,40 @@ + + + {{title}} + {{total}} 款 + + + + + + + + {{item.name}} + {{item.description}} + + {{item.categoryName}} + ¥{{item.price}} + + + + + + + + + 没有匹配的饮品 + 换个关键词试试,或浏览全部 + + + + + {{items.length === 0 ? '浏览全部饮品' : '查看全部饮品 →'}} + + diff --git a/miniprogram/skills/drink-skill/components/recommended-drinks/index.wxss b/miniprogram/skills/drink-skill/components/recommended-drinks/index.wxss new file mode 100644 index 00000000..89264286 --- /dev/null +++ b/miniprogram/skills/drink-skill/components/recommended-drinks/index.wxss @@ -0,0 +1,185 @@ +.rec-card { + background: #FFFFFF; + border-radius: 24rpx; + padding: 24rpx; + box-shadow: 0 6rpx 24rpx rgba(92, 58, 33, 0.08); + width: 100%; + box-sizing: border-box; +} + +.rec-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 18rpx; +} + +.rec-title { + font-size: 30rpx; + font-weight: 700; + color: #2A1810; +} + +.rec-sub { + font-size: 22rpx; + color: #A89180; +} + +.rec-list { + display: flex; + flex-direction: column; +} + +.rec-item { + display: flex; + flex-direction: row; + align-items: center; + background: #FFF8F0; + border-radius: 18rpx; + padding: 16rpx; + box-sizing: border-box; + margin-bottom: 14rpx; +} + +.rec-item:last-child { + margin-bottom: 0; +} + +.rec-item-hover { + opacity: 0.88; + transform: scale(0.99); +} + +.rec-img { + width: 120rpx; + height: 120rpx; + border-radius: 16rpx; + background-color: #F5EFE6; + flex-shrink: 0; +} + +.rec-info { + flex: 1; + min-width: 0; + margin-left: 18rpx; + display: flex; + flex-direction: column; + justify-content: center; +} + +.rec-name { + font-size: 28rpx; + font-weight: 600; + color: #2A1810; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rec-desc { + font-size: 22rpx; + color: #8B5E3C; + margin-top: 6rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rec-foot { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-top: 8rpx; +} + +.rec-cat { + font-size: 20rpx; + color: #A89180; + background: #FFFFFF; + padding: 2rpx 12rpx; + border-radius: 8rpx; +} + +.rec-price { + font-size: 30rpx; + font-weight: 700; + color: #5C3A21; +} + +.rec-more { + margin-top: 18rpx; + padding: 18rpx; + border-radius: 14rpx; + background: linear-gradient(135deg, #8B5E3C 0%, #5C3A21 100%); + text-align: center; +} + +.rec-more-hover { + opacity: 0.85; +} + +.rec-more-text { + color: #FFFFFF; + font-size: 26rpx; + font-weight: 600; + letter-spacing: 1rpx; +} + +.rec-empty { + padding: 30rpx 16rpx; + text-align: center; +} + +.rec-empty-title { + font-size: 28rpx; + color: #5C3A21; + font-weight: 600; +} + +.rec-empty-sub { + font-size: 22rpx; + color: #A89180; + margin-top: 8rpx; +} + +@media (prefers-color-scheme: dark) { + .rec-card { + background: #1E1E1E; + box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.3); + } + .rec-title { + color: #E8E0D8; + } + .rec-sub { + color: #7A6E62; + } + .rec-item { + background: #2A2218; + } + .rec-img { + background-color: #2A2A2A; + } + .rec-name { + color: #E8E0D8; + } + .rec-desc { + color: #D4A574; + } + .rec-cat { + color: #7A6E62; + background: #1E1E1E; + } + .rec-price { + color: #D4A574; + } + .rec-more { + background: linear-gradient(135deg, #A07050 0%, #7A5030 100%); + } + .rec-empty-title { + color: #D4A574; + } + .rec-empty-sub { + color: #7A6E62; + } +} diff --git a/miniprogram/skills/drink-skill/data/seed.js b/miniprogram/skills/drink-skill/data/seed.js new file mode 100644 index 00000000..1bdc9489 --- /dev/null +++ b/miniprogram/skills/drink-skill/data/seed.js @@ -0,0 +1,127 @@ +const CATEGORIES = [ + { id: 141, name: '黑咖/果咖' }, + { id: 133, name: '奶咖' }, + { id: 136, name: '蔬果汁' }, + { id: 135, name: '水果茶' }, + { id: 139, name: '奶茶/暖饮' } +] + +const DRINKS = [ + { id: 289, name: '美式', price: 15, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + { id: 293, name: '白桃茶美式', price: 16, categoryId: 141, categoryName: '黑咖/果咖', description: '清爽茶香', imageUrl: '' }, + { id: 295, name: '杨桃油柑美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '鲜油柑+杨桃', imageUrl: '' }, + { id: 353, name: '凤梨美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '推荐', imageUrl: '' }, + { id: 362, name: '葡萄美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + { id: 294, name: '爆柠美式', price: 18, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + { id: 296, name: '鲜橙美式', price: 20, categoryId: 141, categoryName: '黑咖/果咖', description: '', imageUrl: '' }, + + { id: 393, name: '焦糖蜂窝拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 395, name: '小黄油拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 368, name: '黄油Dirty', price: 20, categoryId: 133, categoryName: '奶咖', description: '到店制作 大口喝', imageUrl: '' }, + { id: 390, name: '轻芝士拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '法国kiri芝士', imageUrl: '' }, + { id: 195, name: '拿铁', price: 18, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 384, name: 'SOE澳白', price: 18, categoryId: 133, categoryName: '奶咖', description: '小热杯 更"咖啡"!', imageUrl: '' }, + { id: 383, name: '秋玫瑰拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '玫瑰与奶咖的双向奔赴', imageUrl: '' }, + { id: 299, name: '生椰拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 297, name: '橘皮拿铁', price: 20, categoryId: 133, categoryName: '奶咖', description: '', imageUrl: '' }, + { id: 208, name: '燕麦拿铁', price: 22, categoryId: 133, categoryName: '奶咖', description: 'OATLY燕麦奶', imageUrl: '' }, + + { id: 394, name: '杨桃+油柑', price: 18, categoryId: 136, categoryName: '蔬果汁', description: '', imageUrl: '' }, + { id: 389, name: '胡萝卜汁', price: 13, categoryId: 136, categoryName: '蔬果汁', description: '补充维生素A', imageUrl: '' }, + { id: 386, name: '青瓜+西芹+苹果', price: 15, categoryId: 136, categoryName: '蔬果汁', description: '全能果蔬饮就它了', imageUrl: '' }, + { id: 387, name: '青瓜+雪梨+柠檬', price: 15, categoryId: 136, categoryName: '蔬果汁', description: '一口喝出清润好气色', imageUrl: '' }, + { id: 313, name: '苹果胡萝卜汁', price: 17, categoryId: 136, categoryName: '蔬果汁', description: '现榨无糖0负担', imageUrl: '' }, + + { id: 315, name: '蜂蜜柚子茶', price: 12, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 300, name: '海盐柠檬', price: 13, categoryId: 135, categoryName: '水果茶', description: '补充电解质', imageUrl: '' }, + { id: 302, name: '鸭屎香柠檬茶', price: 13, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 301, name: '白桃乌龙柠檬茶', price: 13, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 305, name: '满杯百香果', price: 13, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 306, name: '百香益力多', price: 16, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 307, name: '柠檬益力多', price: 16, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 364, name: '黑加仑柠檬茶', price: 16, categoryId: 135, categoryName: '水果茶', description: '', imageUrl: '' }, + { id: 303, name: '杨桃油柑柠檬茶', price: 16, categoryId: 135, categoryName: '水果茶', description: '鲜油柑榨+杨桃', imageUrl: '' }, + + { id: 371, name: '抹茶脑袋', price: 17, categoryId: 139, categoryName: '奶茶/暖饮', description: '冷热皆宜', imageUrl: '' }, + { id: 372, name: '茶萃奶白', price: 16, categoryId: 139, categoryName: '奶茶/暖饮', description: '冷热皆宜', imageUrl: '' }, + { id: 316, name: '黑糖姜母茶', price: 12, categoryId: 139, categoryName: '奶茶/暖饮', description: '热饮 红枣配枸杞', imageUrl: '' }, + { id: 381, name: '红糖姜桃胶撞奶', price: 18, categoryId: 139, categoryName: '奶茶/暖饮', description: '热饮 姜跟牛奶很般配', imageUrl: '' } +] + +function buildSkuSchema(categoryId) { + const tempDim = { + key: 'temperature', label: '温度', multiple: false, + options: [ + { value: 'ice', label: '冰' }, + { value: 'hot', label: '热' } + ] + } + const sugarDim = { + key: 'sugar', label: '糖度', multiple: false, + options: [ + { value: 'none', label: '无糖' }, + { value: 'less', label: '少糖' }, + { value: 'normal', label: '标准' }, + { value: 'more', label: '多糖' } + ] + } + const cupDim = { + key: 'cupSize', label: '杯型', multiple: false, + options: [ + { value: 'medium', label: '中杯', extraPrice: 0 }, + { value: 'large', label: '大杯', extraPrice: 3 }, + { value: 'xlarge', label: '超大杯', extraPrice: 6 } + ] + } + const milkToppings = { + key: 'toppings', label: '加料', multiple: true, + options: [ + { value: 'oatMilk', label: '燕麦奶', extraPrice: 3 }, + { value: 'coconut', label: '椰浆', extraPrice: 3 }, + { value: 'extraShot', label: '额外浓缩', extraPrice: 5 } + ] + } + const teaToppings = { + key: 'toppings', label: '加料', multiple: true, + options: [ + { value: 'pearl', label: '珍珠', extraPrice: 2 }, + { value: 'jelly', label: '椰果', extraPrice: 2 } + ] + } + const juiceCupDim = { + key: 'cupSize', label: '杯型', multiple: false, + options: [ + { value: 'medium', label: '中杯', extraPrice: 0 }, + { value: 'large', label: '大杯', extraPrice: 3 } + ] + } + + switch (categoryId) { + case 141: + return { dimensions: [tempDim, sugarDim] } + case 133: + return { dimensions: [tempDim, sugarDim, cupDim, milkToppings] } + case 136: + return { dimensions: [tempDim, juiceCupDim] } + case 135: + return { dimensions: [tempDim, sugarDim, cupDim] } + case 139: + return { dimensions: [tempDim, sugarDim, cupDim, teaToppings] } + default: + return { dimensions: [tempDim, sugarDim] } + } +} + +function buildCatalog() { + return DRINKS.map(d => ({ + ...d, + skuSchema: buildSkuSchema(d.categoryId) + })) +} + +module.exports = { + CATEGORIES, + DRINKS, + buildCatalog, + buildSkuSchema +} diff --git a/miniprogram/skills/drink-skill/index.js b/miniprogram/skills/drink-skill/index.js new file mode 100644 index 00000000..60854eac --- /dev/null +++ b/miniprogram/skills/drink-skill/index.js @@ -0,0 +1,21 @@ +const searchDrinks = require('./apis/searchDrinks.js') +const selectDrink = require('./apis/selectDrink.js') +const createOrder = require('./apis/createOrder.js') +const addToOrder = require('./apis/addToOrder.js') +const removeOrderItem = require('./apis/removeOrderItem.js') +const viewOrder = require('./apis/viewOrder.js') +const cancelOrder = require('./apis/cancelOrder.js') +const saveAddress = require('./apis/saveAddress.js') + +const skill = wx.modelContext.createSkill('skills/drink-skill') + +skill.registerAPI('searchDrinks', searchDrinks) +skill.registerAPI('selectDrink', selectDrink) +skill.registerAPI('createOrder', createOrder) +skill.registerAPI('addToOrder', addToOrder) +skill.registerAPI('removeOrderItem', removeOrderItem) +skill.registerAPI('viewOrder', viewOrder) +skill.registerAPI('cancelOrder', cancelOrder) +skill.registerAPI('saveAddress', saveAddress) + +console.log('[drink-skill] APIs registered via createSkill') diff --git a/miniprogram/skills/drink-skill/mcp.json b/miniprogram/skills/drink-skill/mcp.json new file mode 100644 index 00000000..0b5886b7 --- /dev/null +++ b/miniprogram/skills/drink-skill/mcp.json @@ -0,0 +1,346 @@ +{ + "apis": [ + { + "name": "searchDrinks", + "description": "检索 / 浏览饮品列表(业务对象:饮品结果卡片)。\n【keyword 有值】按商品名或分类关键词检索(如「拿铁」「奶茶」「水果茶」)。\n【keyword 留空】返回全部饮品,用于用户「随便看看 / 浏览全部 / 想喝点什么 / 推荐一下」等模糊意图。\n【取值约束】keyword 必须来自用户原话,禁止编造用户没说过的具体商品名;用户没给具体词时留空走全部,不要瞎猜。", + "inputSchema": { + "type": "object", + "properties": { + "keyword": { + "type": "string", + "description": "(选填)商品名或分类关键词,取值来自用户原话。用户明确说了饮品名/品类时填写按词检索;用户只是想随便看看 / 浏览全部 / 表达模糊意图时留空,返回全部饮品。【禁止编造】禁止填写用户没说过的具体商品名。" + } + } + }, + "outputSchema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "匹配的饮品列表", + "items": { + "type": "object", + "properties": { + "drinkId": { "type": "number", "description": "商品唯一 ID" }, + "name": { "type": "string", "description": "商品名称" }, + "price": { "type": "number", "description": "基础价格(元)" }, + "categoryName": { "type": "string", "description": "分类名" }, + "description": { "type": "string", "description": "商品简介" }, + "pagePath": { "type": "string", "format": "page-link", "x-link-text-field": "name" } + } + } + }, + "total": { "type": "number", "description": "匹配总数" }, + "hasMore": { "type": "boolean", "description": "是否有更多结果" }, + "keyword": { "type": "string", "description": "用户搜索关键词" } + } + }, + "_meta": { "ui": { "componentPath": "components/recommended-drinks/index", "pagePath": "/packageWeStoreCoffee/pages/home/home" } } + }, + + { + "name": "selectDrink", + "description": "查看某款饮品详情及可选规格(业务对象:饮品详情卡片)。\n调用前置条件:已从 searchDrinks 返回的 items 中获取到具体的 drinkId。\n【严禁场景】禁止在未获得有效 drinkId 的情况下调用本接口,禁止从用户自然语言推断 drinkId。\n【后续动作】本接口返回 handoff 并出小程序卡片;用一句简短话术引导用户点击卡片进入饮品详情 / 选规格接力页,在页面内完成选规格、加入订单及支付等后续操作。", + "inputSchema": { + "type": "object", + "properties": { + "drinkId": { + "type": "number", + "description": "商品唯一标识,必须来自上游接口 searchDrinks 返回的 items[].drinkId 字段原值。【禁止编造】禁止从用户自然语言(如『那个 3 号』)推断或截取,禁止使用示例值。上下文中无可用 drinkId 时,应先调 searchDrinks。" + } + }, + "required": ["drinkId"] + }, + "outputSchema": { + "type": "object", + "properties": { + "drinkId": { "type": "number" }, + "name": { "type": "string" }, + "price": { "type": "number", "description": "基础价格(元)" }, + "description": { "type": "string" }, + "categoryName": { "type": "string" }, + "specOptions": { + "type": "object", + "description": "可选规格维度,key 为维度 key(temperature/sugar/cupSize/toppings),每个维度含 options 数组" + } + } + }, + "_meta": { "ui": { "componentPath": "components/drink-detail-card/index", "pagePath": "/packageWeStoreCoffee/pages/sku-picker/sku-picker" } } + }, + { + "name": "createOrder", + "description": "确认饮品规格并【新建】订单(业务对象:订单确认卡片)。用户「就要这一杯 / 换一杯 / 重新点」时调用本接口。\n调用前置条件:已调用 selectDrink 成功获取到饮品详情(即上下文中有 selectDrink 返回的 drinkId 和 specOptions)。\n【单次仅一杯】本接口每次只处理一杯饮品(一个 drinkId + 一套 specs),不支持一次下多杯/多款;用户要多杯时先用本接口下第一杯,其余每杯各调一次 addToOrder 追加。\n【单次购买语义】整个点单链路同一时刻只维护一个活动订单。本接口每次调用都会新建订单并覆盖之前的活动订单;若用户是要在已有订单基础上「再加一杯 / 追加」,应改调 addToOrder,不要用本接口。\n【严禁场景】禁止未经 selectDrink 直接调用本接口;禁止使用中文 label 作为 specs value(必须使用英文枚举值)。", + "inputSchema": { + "type": "object", + "properties": { + "drinkId": { + "type": "number", + "description": "商品唯一标识,必须来自上一步 selectDrink 返回的 structuredContent.drinkId 原值。【禁止编造】禁止从用户语言推断。" + }, + "specs": { + "type": "object", + "description": "规格对象,key 为维度 key,value 必须使用英文枚举值。取值来源:selectDrink 返回的 specOptions 中各维度的 options[].value。【禁止编造】禁止使用中文 label 或不在枚举中的值。", + "properties": { + "temperature": { + "type": "string", + "description": "温度。仅允许:'ice'(冰)/ 'hot'(热)。【仅当用户明确说了冷热时才传】用户说「热的」→ 'hot';用户没提温度时不要传本字段(系统默认 'ice' 冰),严禁在用户未说温度时自行猜成 'hot'。", + "enum": ["ice", "hot"] + }, + "sugar": { + "type": "string", + "description": "糖度。仅允许:'none'(无糖)/ 'less'(少糖)/ 'normal'(标准)/ 'more'(多糖)。【仅当用户明确说了糖度时才传】用户没提糖度时不要传本字段(系统默认 'normal' 标准)。", + "enum": ["none", "less", "normal", "more"] + }, + "cupSize": { + "type": "string", + "description": "杯型。仅允许:'medium'(中杯)/ 'large'(大杯)/ 'xlarge'(超大杯,仅部分品类支持)。【仅当用户明确说了杯型时才传】用户没提杯型时不要传本字段(系统默认 'medium' 中杯)。", + "enum": ["medium", "large", "xlarge"] + }, + "toppings": { + "type": "array", + "description": "加料(多选)。元素仅允许:'oatMilk'(燕麦奶)/ 'coconut'(椰浆)/ 'extraShot'(额外浓缩,仅奶咖)/ 'pearl'(珍珠)/ 'jelly'(椰果)。用户未指定时传空数组 []。", + "items": { + "type": "string", + "enum": ["oatMilk", "coconut", "extraShot", "pearl", "jelly"] + } + } + } + } + }, + "required": ["drinkId", "specs"] + }, + "outputSchema": { + "type": "object", + "properties": { + "orderId": { "type": "string", "description": "订单 ID" }, + "items": { + "type": "array", + "description": "订单内已选商品列表", + "items": { + "type": "object", + "properties": { + "itemId": { "type": "string", "description": "订单商品项唯一标识,删除商品(removeOrderItem)时用于定位" }, + "drinkName": { "type": "string", "description": "饮品名称" }, + "specText": { "type": "string", "description": "规格文本描述" }, + "totalPrice": { "type": "number", "description": "该商品小计(元)" } + } + } + }, + "itemCount": { "type": "number", "description": "订单内商品件数" }, + "totalPrice": { "type": "number", "description": "订单总价(元)" }, + "needAddress": { "type": "boolean", "description": "true 表示用户需在结算页补充收货地址" }, + "status": { "type": "string", "description": "订单状态:pending / confirmed" } + } + }, + "_meta": { "ui": { "componentPath": "components/order-confirm-card/index", "pagePath": "/packageWeStoreCoffee/pages/checkout/checkout" } } + }, + { + "name": "addToOrder", + "description": "向【当前进行中的订单】追加一款饮品(业务对象:订单确认卡片,含全部已选商品)。用户「再加一杯 / 还要一个 / 顺便来个X / 多来一份」等追加意图时调用本接口。\n调用前置条件:已调用 selectDrink 成功获取到本次追加饮品的 drinkId 和 specOptions。\n【单次仅一杯】本接口每次只追加一杯饮品(一个 drinkId + 一套 specs),不支持一次追加多杯/多款;用户一次要多杯时,需逐杯各调一次本接口。\n【单次购买语义】只维护一个活动订单:有进行中的订单则把本杯追加进去;若当前没有进行中的订单,则以本杯新建订单(因此追加意图下调用本接口总是安全的,不会因无订单而失败)。追加后 handoff 进结算页展示全部已选商品与合计。\n【严禁场景】① 禁止未经 selectDrink 直接调用;② 禁止使用中文 label 作为 specs value(必须使用英文枚举值)。", + "inputSchema": { + "type": "object", + "properties": { + "drinkId": { + "type": "number", + "description": "要追加的商品唯一标识,必须来自上一步 selectDrink 返回的 structuredContent.drinkId 原值。【禁止编造】禁止从用户语言推断。" + }, + "specs": { + "type": "object", + "description": "追加商品的规格对象,key 为维度 key,value 必须使用英文枚举值。取值来源:selectDrink 返回的 specOptions 中各维度的 options[].value。【禁止编造】禁止使用中文 label 或不在枚举中的值。", + "properties": { + "temperature": { + "type": "string", + "description": "温度。仅允许:'ice'(冰)/ 'hot'(热)。【仅当用户明确说了冷热时才传】用户没提温度时不要传本字段(系统默认 'ice' 冰)。", + "enum": ["ice", "hot"] + }, + "sugar": { + "type": "string", + "description": "糖度。仅允许:'none'(无糖)/ 'less'(少糖)/ 'normal'(标准)/ 'more'(多糖)。【仅当用户明确说了糖度时才传】用户没提糖度时不要传本字段(系统默认 'normal' 标准)。", + "enum": ["none", "less", "normal", "more"] + }, + "cupSize": { + "type": "string", + "description": "杯型。仅允许:'medium'(中杯)/ 'large'(大杯)/ 'xlarge'(超大杯,仅部分品类支持)。【仅当用户明确说了杯型时才传】用户没提杯型时不要传本字段(系统默认 'medium' 中杯)。", + "enum": ["medium", "large", "xlarge"] + }, + "toppings": { + "type": "array", + "description": "加料(多选)。元素仅允许:'oatMilk'(燕麦奶)/ 'coconut'(椰浆)/ 'extraShot'(额外浓缩,仅奶咖)/ 'pearl'(珍珠)/ 'jelly'(椰果)。用户未指定时传空数组 []。", + "items": { + "type": "string", + "enum": ["oatMilk", "coconut", "extraShot", "pearl", "jelly"] + } + } + } + } + }, + "required": ["drinkId", "specs"] + }, + "outputSchema": { + "type": "object", + "properties": { + "orderId": { "type": "string", "description": "被追加的订单 ID" }, + "items": { + "type": "array", + "description": "追加后订单内全部已选商品列表", + "items": { + "type": "object", + "properties": { + "itemId": { "type": "string", "description": "订单商品项唯一标识,删除商品(removeOrderItem)时用于定位" }, + "drinkName": { "type": "string", "description": "饮品名称" }, + "specText": { "type": "string", "description": "规格文本描述" }, + "totalPrice": { "type": "number", "description": "该商品小计(元)" } + } + } + }, + "itemCount": { "type": "number", "description": "追加后订单内商品件数" }, + "totalPrice": { "type": "number", "description": "追加后订单总价(元)" }, + "needAddress": { "type": "boolean", "description": "true 表示用户需在结算页补充收货地址" }, + "status": { "type": "string", "description": "订单状态:pending / confirmed" } + } + }, + "_meta": { "ui": { "componentPath": "components/order-confirm-card/index", "pagePath": "/packageWeStoreCoffee/pages/checkout/checkout" } } + }, + { + "name": "removeOrderItem", + "description": "从【当前进行中的订单】删除一款已选商品(业务对象:订单确认卡片,含剩余商品)。用户「去掉/删掉/不要那杯X / 把X删了」等删除意图时调用本接口。\n【铁律:至少保留一件】当订单仅剩最后一件商品时禁止删除,接口会返回错误并要求你直接告知用户「仅剩一件,无法删除」,此时禁止重试。\n【改规格场景】用户要改某商品规格时:若订单有多件,先用本接口删除旧商品项、再用 addToOrder 追加新规格;若订单仅此一件(删不掉最后一件),改用 createOrder 以新规格覆盖整单。\n调用前置条件:从订单返回值 structuredContent.items[].itemId 中取到要删除商品项的 itemId。\n【严禁场景】禁止编造 itemId;itemId 必须来自订单接口返回的 items[].itemId 原值。", + "inputSchema": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "description": "要删除的订单商品项唯一标识。取值来源:createOrder / addToOrder / saveAddress / removeOrderItem 返回的 structuredContent.items[].itemId 原值。【禁止编造】禁止臆造或从饮品名推断。" + } + }, + "required": ["itemId"] + }, + "outputSchema": { + "type": "object", + "properties": { + "orderId": { "type": "string", "description": "被操作的订单 ID" }, + "items": { + "type": "array", + "description": "删除后订单内剩余商品列表", + "items": { + "type": "object", + "properties": { + "itemId": { "type": "string", "description": "订单商品项唯一标识" }, + "drinkName": { "type": "string", "description": "饮品名称" }, + "specText": { "type": "string", "description": "规格文本描述" }, + "totalPrice": { "type": "number", "description": "该商品小计(元)" } + } + } + }, + "itemCount": { "type": "number", "description": "删除后订单内商品件数" }, + "totalPrice": { "type": "number", "description": "删除后订单总价(元)" }, + "needAddress": { "type": "boolean", "description": "true 表示用户需在结算页补充收货地址" }, + "status": { "type": "string", "description": "订单状态:pending / confirmed" } + } + }, + "_meta": { "ui": { "componentPath": "components/order-confirm-card/index", "pagePath": "/packageWeStoreCoffee/pages/checkout/checkout" } } + }, + { + "name": "viewOrder", + "description": "查看当前进行中的订单(业务对象:订单确认卡片,含全部已选商品)。用户「看看我的订单 / 我点了什么 / 查看购物车 / 订单里有啥 / 一共多少钱」等查看意图时调用本接口。\n【语义】只查看当前活动订单(未支付);若没有进行中的订单,接口会返回错误并要求你直接告知用户暂无订单,此时禁止重试。\n【无入参】本接口不需要任何参数。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "orderId": { "type": "string", "description": "当前订单 ID" }, + "items": { + "type": "array", + "description": "订单内全部已选商品列表", + "items": { + "type": "object", + "properties": { + "itemId": { "type": "string", "description": "订单商品项唯一标识" }, + "drinkName": { "type": "string", "description": "饮品名称" }, + "specText": { "type": "string", "description": "规格文本描述" }, + "totalPrice": { "type": "number", "description": "该商品小计(元)" } + } + } + }, + "itemCount": { "type": "number", "description": "订单内商品件数" }, + "totalPrice": { "type": "number", "description": "订单总价(元)" }, + "needAddress": { "type": "boolean", "description": "true 表示用户需在结算页补充收货地址" }, + "status": { "type": "string", "description": "订单状态:pending / confirmed" } + } + }, + "_meta": { "ui": { "componentPath": "components/order-confirm-card/index", "pagePath": "/packageWeStoreCoffee/pages/checkout/checkout" } } + }, + { + "name": "cancelOrder", + "description": "取消当前进行中的订单(清空全部已选商品)。用户「取消订单 / 不要了 / 清空购物车 / 全部删掉 / 重新点」等整单取消意图时调用本接口。\n【语义】只作用于当前未支付的活动订单;取消后订单清空、活动订单失效,handoff 回首页让用户重新挑选。\n【与 removeOrderItem 区别】removeOrderItem 只删单件且至少保留一件;本接口是清空整单。用户想清掉最后一件 / 清空全部时用本接口。\n【无进行中订单】若当前没有活动订单,接口返回错误并要求你直接告知用户暂无可取消的订单,此时禁止重试。\n【无入参】本接口不需要任何参数。", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "description": "是否取消成功" }, + "orderId": { "type": "string", "description": "被取消的订单 ID" }, + "canceledCount": { "type": "number", "description": "被清空的商品件数" }, + "status": { "type": "string", "description": "订单状态:canceled" } + } + }, + "_meta": { "ui": { "pagePath": "/packageWeStoreCoffee/pages/home/home" } } + }, + { + "name": "saveAddress", + "description": "保存用户【本次新提供的】收货地址并接力进入结算页(业务对象:订单确认卡片,含全部已选商品)。若存在进行中的活动订单,接口会把地址写入订单、更新为 confirmed,并通过 handoff 让用户点击卡片进入结算页确认支付。\n调用前置条件:用户在本轮对话中【明确说出了完整的新地址】(收货人姓名 + 手机号 + 详细地址三项齐全)。\n【严禁场景】① 用户只是表达『改/换/更新收货地址』等意图、但尚未给出具体新地址时,禁止调用本接口,必须先以追问的方式向用户确认新的收货人、手机号、详细地址;② 禁止使用上下文里的旧地址 / 已保存地址占位,重复保存同一个地址;③ 三个必填字段任一缺失时禁止调用,应先追问补齐后再调用。", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "收货人姓名。取值来源:用户本轮提供的新地址原话。【禁止编造 / 禁止沿用旧值】用户本轮未明确给出时禁止填写,应先追问用户。" + }, + "phone": { + "type": "string", + "description": "手机号,11 位数字。取值来源:用户本轮提供的新地址原话。【禁止编造 / 禁止沿用旧值】用户本轮未明确给出时禁止填写,应先追问用户。" + }, + "detail": { + "type": "string", + "description": "详细地址。取值来源:用户本轮提供的新地址原话。【禁止编造 / 禁止沿用旧值】用户本轮未明确给出时禁止填写,应先追问用户。" + } + }, + "required": ["name", "phone", "detail"] + }, + "outputSchema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "description": "是否保存成功" }, + "orderId": { "type": "string", "description": "续跑的活动订单 ID(无活动订单时不存在)" }, + "items": { + "type": "array", + "description": "订单内全部已选商品列表", + "items": { + "type": "object", + "properties": { + "itemId": { "type": "string", "description": "订单商品项唯一标识,删除商品(removeOrderItem)时用于定位" }, + "drinkName": { "type": "string", "description": "饮品名称" }, + "specText": { "type": "string", "description": "规格文本描述" }, + "totalPrice": { "type": "number", "description": "该商品小计(元)" } + } + } + }, + "itemCount": { "type": "number", "description": "订单内商品件数" }, + "totalPrice": { "type": "number", "description": "订单总价" }, + "needAddress": { "type": "boolean", "description": "是否仍缺地址" }, + "status": { "type": "string", "description": "订单状态" } + } + }, + "_meta": { "ui": { "componentPath": "components/order-confirm-card/index", "pagePath": "/packageWeStoreCoffee/pages/checkout/checkout" } } + } + ], + "components": [ + { "path": "components/recommended-drinks/index", "relatedPage": "/packageWeStoreCoffee/pages/home/home" }, + { "path": "components/drink-detail-card/index", "relatedPage": "/packageWeStoreCoffee/pages/sku-picker/sku-picker" }, + { "path": "components/order-confirm-card/index", "relatedPage": "/packageWeStoreCoffee/pages/checkout/checkout" }, + { "path": "components/drink-not-found-card/index", "relatedPage": "/packageWeStoreCoffee/pages/home/home" } + ] +} diff --git a/miniprogram/skills/drink-skill/utils/id.js b/miniprogram/skills/drink-skill/utils/id.js new file mode 100644 index 00000000..a3affac2 --- /dev/null +++ b/miniprogram/skills/drink-skill/utils/id.js @@ -0,0 +1,13 @@ +function genOrderId() { + const ts = Date.now().toString(36) + const rand = Math.floor(Math.random() * 1e4).toString(36).padStart(3, '0') + return `O${ts}${rand}`.toUpperCase() +} + +function genItemId() { + const ts = Date.now().toString(36) + const rand = Math.floor(Math.random() * 1e4).toString(36).padStart(3, '0') + return `IT${ts}${rand}`.toUpperCase() +} + +module.exports = { genOrderId, genItemId } diff --git a/miniprogram/skills/drink-skill/utils/sku.js b/miniprogram/skills/drink-skill/utils/sku.js new file mode 100644 index 00000000..0554ce7e --- /dev/null +++ b/miniprogram/skills/drink-skill/utils/sku.js @@ -0,0 +1,100 @@ +const { findDrink } = require('./storage.js') +const { genItemId } = require('./id.js') + +function resolveOption(options, rawVal) { + if (rawVal == null) return null + const v = String(rawVal).trim() + const vLower = v.toLowerCase() + let opt = options.find(o => o.value === v) + if (opt) return opt + opt = options.find(o => String(o.value).toLowerCase() === vLower) + if (opt) return opt + opt = options.find(o => o.label === v) + if (opt) return opt + opt = options.find(o => String(o.label).replace(/\s+/g, '') === v.replace(/\s+/g, '')) + if (opt) return opt + return null +} + +const DEFAULT_SINGLE_SPEC = { temperature: 'ice', sugar: 'normal', cupSize: 'medium' } + +function validateSpecs(drinkId, rawSpecs) { + const drink = findDrink(drinkId) + if (!drink) { + return { valid: false, error: `未找到 id 为 ${drinkId} 的饮品` } + } + const schema = drink.skuSchema + const specs = rawSpecs || {} + const normalized = {} + const textParts = [] + let extraPrice = 0 + + for (const dim of schema.dimensions) { + const val = specs[dim.key] + if (dim.multiple) { + const arr = Array.isArray(val) ? val : (val ? [val] : []) + const validValues = [] + const labels = [] + for (const v of arr) { + const opt = resolveOption(dim.options, v) + if (opt) { + validValues.push(opt.value) + labels.push(opt.label) + extraPrice += (opt.extraPrice || 0) + } else { + const allow = dim.options.map(o => `${o.value}(${o.label})`).join('、') + return { valid: false, error: `${dim.label} 不支持 "${v}",可选:${allow}` } + } + } + normalized[dim.key] = validValues + if (labels.length) textParts.push(`${dim.label}:${labels.join('+')}`) + } else { + const useVal = (val != null && String(val).trim()) + ? val + : (DEFAULT_SINGLE_SPEC[dim.key] || (dim.options[0] && dim.options[0].value)) + const opt = resolveOption(dim.options, useVal) + if (!opt) { + const allow = dim.options.map(o => `${o.value}(${o.label})`).join('、') + return { valid: false, error: `${dim.label} 不支持 "${val}",可选:${allow}` } + } + normalized[dim.key] = opt.value + extraPrice += (opt.extraPrice || 0) + textParts.push(`${opt.label}`) + } + } + + const totalPrice = drink.price + extraPrice + return { + valid: true, + normalizedSpecs: normalized, + specText: textParts.join(' / '), + totalPrice, + basePrice: drink.price, + extraPrice + } +} + +function buildOrderItem(drinkId, rawSpecs) { + const drink = findDrink(drinkId) + if (!drink) { + return { valid: false, error: `未找到 drinkId=${drinkId} 的饮品` } + } + const check = validateSpecs(drinkId, rawSpecs) + if (!check.valid) return check + return { + valid: true, + item: { + itemId: genItemId(), + drinkId: drink.id, + drinkName: drink.name, + imageUrl: drink.imageUrl, + specs: check.normalizedSpecs, + specText: check.specText, + basePrice: check.basePrice, + extraPrice: check.extraPrice, + totalPrice: check.totalPrice + } + } +} + +module.exports = { validateSpecs, buildOrderItem } diff --git a/miniprogram/skills/drink-skill/utils/storage.js b/miniprogram/skills/drink-skill/utils/storage.js new file mode 100644 index 00000000..8f79f87f --- /dev/null +++ b/miniprogram/skills/drink-skill/utils/storage.js @@ -0,0 +1,114 @@ +const { buildCatalog, CATEGORIES } = require('../data/seed.js') + +const CATALOG_KEY = 'drinks_catalog' +const CATEGORIES_KEY = 'drinks_categories' +const CATALOG_VERSION_KEY = 'drinks_catalog_version' +const CATALOG_VERSION = 3 + +function getOpenid() { + const userInfo = wx.getStorageSync('userInfo') + return (userInfo && userInfo.openid) ? userInfo.openid : 'anonymous' +} + +function ensureCatalog() { + let catalog = wx.getStorageSync(CATALOG_KEY) + const version = wx.getStorageSync(CATALOG_VERSION_KEY) + if (!catalog || !catalog.length || version !== CATALOG_VERSION) { + catalog = buildCatalog() + wx.setStorageSync(CATALOG_KEY, catalog) + wx.setStorageSync(CATEGORIES_KEY, CATEGORIES) + wx.setStorageSync(CATALOG_VERSION_KEY, CATALOG_VERSION) + console.log('[drink-skill][storage] catalog seeded v' + CATALOG_VERSION, catalog.length) + } + return catalog +} + +function getCatalog() { + return ensureCatalog() +} + +function getCategories() { + ensureCatalog() + return wx.getStorageSync(CATEGORIES_KEY) || CATEGORIES +} + +function findDrink(drinkId) { + const catalog = getCatalog() + return catalog.find(d => d.id === Number(drinkId)) +} + +function getAddress() { + const openid = getOpenid() + return wx.getStorageSync(`address_${openid}`) || null +} + +function setAddress(addr) { + const openid = getOpenid() + wx.setStorageSync(`address_${openid}`, addr) +} + +function getOrders() { + const openid = getOpenid() + return wx.getStorageSync(`orders_${openid}`) || [] +} + +function saveOrder(order) { + const openid = getOpenid() + const orders = getOrders() + const idx = orders.findIndex(o => o.orderId === order.orderId) + if (idx >= 0) orders[idx] = order + else orders.push(order) + wx.setStorageSync(`orders_${openid}`, orders) +} + +function findOrder(orderId) { + const orders = getOrders() + return orders.find(o => o.orderId === orderId) || null +} + +function getActiveOrderId() { + const openid = getOpenid() + return wx.getStorageSync(`active_order_${openid}`) || null +} + +function setActiveOrderId(orderId) { + const openid = getOpenid() + wx.setStorageSync(`active_order_${openid}`, orderId) +} + +function clearActiveOrderId() { + const openid = getOpenid() + wx.removeStorageSync(`active_order_${openid}`) +} + +function getActiveOrder() { + const id = getActiveOrderId() + if (!id) return null + const order = findOrder(id) + if (!order || order.status === 'paid') return null + return order +} + +function recalcOrderTotal(order) { + const items = Array.isArray(order.items) ? order.items : [] + order.totalPrice = items.reduce((sum, it) => sum + (Number(it.totalPrice) || 0), 0) + order.itemCount = items.length + return order +} + +module.exports = { + getOpenid, + getCatalog, + getCategories, + findDrink, + getAddress, + setAddress, + getOrders, + saveOrder, + findOrder, + getActiveOrderId, + setActiveOrderId, + clearActiveOrderId, + getActiveOrder, + recalcOrderTotal +}