From fd4fd61399d6ef6ab20bc70ffb6b407e457aa861 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 8 Jul 2026 11:48:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E5=8D=A1=E7=89=87=E7=BB=84=E4=BB=B6=E4=B8=8E=E9=85=8D?= =?UTF-8?q?=E5=A5=97=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增2*4商品横滑卡片、4*4活动促销卡片,重构基础店铺卡片UI;添加商品、活动数据拉取缓存服务,修复TextDecoder兼容问题,优化API拼接逻辑,支持多卡片尺寸适配 --- .../entry/src/main/ets/common/ApiConfig.ets | 53 ++++-- .../main/ets/common/ProductCardService.ets | 163 ++++++++++++++++++ .../src/main/ets/common/PromotionService.ets | 152 ++++++++++++++++ .../src/main/ets/common/ShopInfoService.ets | 65 ++++--- .../ets/entryformability/EntryFormAbility.ets | 96 +++++++++-- .../src/main/ets/widget/pages/WidgetCard.ets | 152 ++++++++++------ .../main/ets/widget/pages/WidgetCardFull.ets | 122 +++++++++++++ .../ets/widget/pages/WidgetCardHorizontal.ets | 108 ++++++++++++ 8 files changed, 806 insertions(+), 105 deletions(-) create mode 100644 stub-app-projects/mp-harmony/entry/src/main/ets/common/ProductCardService.ets create mode 100644 stub-app-projects/mp-harmony/entry/src/main/ets/common/PromotionService.ets create mode 100644 stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardFull.ets create mode 100644 stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardHorizontal.ets diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/common/ApiConfig.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/common/ApiConfig.ets index 05e018e..68ed978 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/ets/common/ApiConfig.ets +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/common/ApiConfig.ets @@ -17,6 +17,36 @@ const FALLBACK_CONFIG: SiteConfig = { let cachedConfig: SiteConfig | null = null; +// 元服务里 util.TextDecoder.decode 被禁(11706010),自行实现 UTF-8 解码 +// site.js 主要是 ASCII + 偶尔中文注释,完整 UTF-8 解码避免匹配出错 +function bytesToString(arr: Uint8Array): string { + let result: string = ''; + let i: number = 0; + const len: number = arr.length; + while (i < len) { + const b1: number = arr[i++]; + if (b1 < 0x80) { + result += String.fromCharCode(b1); + } else if (b1 < 0xE0) { + const b2: number = arr[i++] & 0x3F; + result += String.fromCharCode(((b1 & 0x1F) << 6) | b2); + } else if (b1 < 0xF0) { + const b2: number = arr[i++] & 0x3F; + const b3: number = arr[i++] & 0x3F; + result += String.fromCharCode(((b1 & 0x0F) << 12) | (b2 << 6) | b3); + } else { + const b2: number = arr[i++] & 0x3F; + const b3: number = arr[i++] & 0x3F; + const b4: number = arr[i++] & 0x3F; + let cp: number = ((b1 & 0x07) << 18) | (b2 << 12) | (b3 << 6) | b4; + cp -= 0x10000; + result += String.fromCharCode(0xD800 + (cp >> 10)); + result += String.fromCharCode(0xDC00 + (cp & 0x3FF)); + } + } + return result; +} + // 解析 site.js (CommonJS 风格: module.exports = { baseUrl: "x", uniacid: y }) // 不做 eval/Function 构造,仅用正则提取 function parseSiteJs(text: string): SiteConfig { @@ -33,7 +63,7 @@ export async function getSiteConfig(context: common.Context): Promise): string { +// 拼接 URL:内部直接拼 uniacid,避免 ArkTS 1.1 不支持的动态字段访问 +// extraQuery 用于未来扩展其他参数,格式 "&key=value" (注意带前导 &) +export function buildApiUrl(config: SiteConfig, path: string, extraQuery?: string): string { const base: string = config.baseUrl.endsWith('/') ? config.baseUrl.slice(0, -1) : config.baseUrl; const p: string = path.startsWith('/') ? path : `/${path}`; - let url: string = `${base}${p}`; - if (query && Object.keys(query).length > 0) { - const parts: string[] = Object.entries(query).map((entry: [string, string | number]): string => { - const k: string = encodeURIComponent(entry[0]); - const v: string = encodeURIComponent(String(entry[1])); - return `${k}=${v}`; - }); - url = `${url}?${parts.join('&')}`; + const urlWithUniacid: string = `${base}${p}?uniacid=${config.uniacid}`; + if (extraQuery && extraQuery.length > 0) { + return `${urlWithUniacid}${extraQuery}`; } - return url; + return urlWithUniacid; } // 店铺信息接口:H5/uni-app 启动时也会调用此接口 -// 响应结构: { code, data: { shop_info: { ... } } } +// 响应结构: { code, data: { shop_info: { ... }, site_info: { ... } } } export const SHOP_INFO_API: string = '/api/config/init'; // HTTP 超时(毫秒) diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/common/ProductCardService.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/common/ProductCardService.ets new file mode 100644 index 0000000..dfd9e03 --- /dev/null +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/common/ProductCardService.ets @@ -0,0 +1,163 @@ +import { preferences } from '@kit.ArkData'; +import { http } from '@kit.NetworkKit'; +import { common } from '@kit.AbilityKit'; +import { getSiteConfig, buildApiUrl, SiteConfig } from './ApiConfig'; + +const PREFERENCES_NAME = 'shop_card_cache'; +const CACHE_KEY_PRODUCTS = 'product_cards_v1'; +const CACHE_TTL_MS = 30 * 60 * 1000; + +export interface ProductItem { + goodsId: string; + goodsName: string; + price: string; + picUrl: string; +} + +const DEFAULT_PRODUCT: ProductItem = { + goodsId: '', + goodsName: '', + price: '', + picUrl: '' +}; + +let prefsInstance: preferences.Preferences | null = null; + +async function getPreferences(context: common.Context): Promise { + if (prefsInstance) { + return prefsInstance; + } + const options: preferences.Options = { name: PREFERENCES_NAME }; + try { + prefsInstance = await preferences.getPreferences(context, options); + } catch (e) { + throw new Error(`getPreferences failed: ${JSON.stringify(e)}`); + } + return prefsInstance; +} + +interface CachedProducts { + _cacheTime: number; + data: ProductItem[]; +} + +interface ProductRaw { + goods_id?: number | string; + goodsId?: number | string; + goods_name?: string; + goodsName?: string; + price?: string | number; + pic_url?: string; + picUrl?: string; + image?: string; + thumb?: string; +} + +interface ProductsApiData { + list?: ProductRaw[]; + goods_list?: ProductRaw[]; + records?: ProductRaw[]; + data?: ProductRaw[]; +} + +interface ProductsApiResponse { + code?: number; + data?: ProductsApiData; +} + +function parseProduct(raw: Object | undefined): ProductItem { + const obj: ProductRaw = (raw as ProductRaw) ?? {}; + const idVal: number | string | undefined = obj.goods_id ?? obj.goodsId; + const id: string = idVal !== undefined ? String(idVal) : DEFAULT_PRODUCT.goodsId; + const name: string = obj.goods_name ?? obj.goodsName ?? DEFAULT_PRODUCT.goodsName; + const priceVal: string | number | undefined = obj.price; + const price: string = priceVal !== undefined ? String(priceVal) : DEFAULT_PRODUCT.price; + const pic: string = obj.pic_url ?? obj.picUrl ?? obj.image ?? obj.thumb ?? DEFAULT_PRODUCT.picUrl; + return { goodsId: id, goodsName: name, price, picUrl: pic }; +} + +export async function getCachedProducts(context: common.Context): Promise { + try { + const prefs: preferences.Preferences = await getPreferences(context); + const cached: string = await prefs.get(CACHE_KEY_PRODUCTS, '') as string; + if (!cached) { + return null; + } + const parsed: CachedProducts = JSON.parse(cached) as CachedProducts; + if (!parsed || typeof parsed._cacheTime !== 'number' || !parsed.data) { + return null; + } + if (Date.now() - parsed._cacheTime > CACHE_TTL_MS) { + return null; + } + return parsed.data; + } catch (e) { + return null; + } +} + +async function writeCache(context: common.Context, items: ProductItem[]): Promise { + try { + const prefs: preferences.Preferences = await getPreferences(context); + const payload: CachedProducts = { _cacheTime: Date.now(), data: items }; + await prefs.put(CACHE_KEY_PRODUCTS, JSON.stringify(payload)); + await prefs.flush(); + } catch (e) { + // 缓存失败不阻塞主流程 + } +} + +// 2x4 卡片商品拉取 +// 接口模式(用户提供): +// POST /api/goodssku/pageByCategory +// Content-Type: application/x-www-form-urlencoded +// body: app_type=h5&uniacid=2&page=1&page_size=3&category_id=0&order=&sort= +// 默认 category_id=0(全部/默认分类),需要时可改 PRODUCT_CATEGORY_ID +const PRODUCT_LIST_API = '/api/goodssku/pageByCategory'; +const PRODUCT_PAGE_SIZE = 4; +const PRODUCT_CATEGORY_ID = 0; + +export async function fetchProducts(context: common.Context): Promise { + const httpRequest: http.HttpRequest = http.createHttp(); + try { + const config: SiteConfig = await getSiteConfig(context); + const url: string = buildApiUrl(config, PRODUCT_LIST_API); + const body: string = `app_type=h5&app_type_name=H5&token=&uniacid=${config.uniacid}&page=1&page_size=${PRODUCT_PAGE_SIZE}&category_id=${PRODUCT_CATEGORY_ID}&order=&sort=`; + const response: http.HttpResponse = await httpRequest.request(url, { + method: http.RequestMethod.POST, + connectTimeout: 8000, + readTimeout: 8000, + header: { 'Content-Type': 'application/x-www-form-urlencoded' }, + extraData: body + }); + if (response.responseCode !== 200) { + throw new Error(`HTTP ${response.responseCode}`); + } + const resultStr: string = typeof response.result === 'string' + ? response.result + : JSON.stringify(response.result); + const json: ProductsApiResponse = JSON.parse(resultStr) as ProductsApiResponse; + const dataObj: ProductsApiData = json.data ?? {}; + const rawList: ProductRaw[] = dataObj.list ?? dataObj.goods_list ?? dataObj.records ?? dataObj.data ?? []; + const items: ProductItem[] = []; + let i: number = 0; + while (i < rawList.length && i < PRODUCT_PAGE_SIZE) { + items.push(parseProduct(rawList[i] as Object)); + i++; + } + await writeCache(context, items); + return items; + } catch (e) { + return []; + } finally { + httpRequest.destroy(); + } +} + +export async function getProductCards(context: common.Context): Promise { + const cached: ProductItem[] | null = await getCachedProducts(context); + if (cached && cached.length > 0) { + return cached; + } + return await fetchProducts(context); +} diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/common/PromotionService.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/common/PromotionService.ets new file mode 100644 index 0000000..c8881f5 --- /dev/null +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/common/PromotionService.ets @@ -0,0 +1,152 @@ +import { preferences } from '@kit.ArkData'; +import { http } from '@kit.NetworkKit'; +import { common } from '@kit.AbilityKit'; +import { getSiteConfig, buildApiUrl, SiteConfig } from './ApiConfig'; + +const PREFERENCES_NAME = 'shop_card_cache'; +const CACHE_KEY_PROMO = 'active_promotion_v1'; +const CACHE_TTL_MS = 30 * 60 * 1000; + +export interface Promotion { + title: string; + image: string; + endTime: number; + link: string; +} + +const DEFAULT_PROMOTION: Promotion = { + title: '限时活动', + image: '', + endTime: 0, + link: '' +}; + +let prefsInstance: preferences.Preferences | null = null; + +async function getPreferences(context: common.Context): Promise { + if (prefsInstance) { + return prefsInstance; + } + const options: preferences.Options = { name: PREFERENCES_NAME }; + try { + prefsInstance = await preferences.getPreferences(context, options); + } catch (e) { + throw new Error(`getPreferences failed: ${JSON.stringify(e)}`); + } + return prefsInstance; +} + +interface CachedPromotion { + _cacheTime: number; + data: Promotion; +} + +interface PromotionRaw { + title?: string; + name?: string; + image?: string; + pic_url?: string; + end_time?: number | string; + endTime?: number | string; + link?: string; + url?: string; + wap_url?: string; +} + +interface PromotionApiData { + promotion?: PromotionRaw; + data?: PromotionRaw; + info?: PromotionRaw; +} + +interface PromotionApiResponse { + code?: number; + data?: PromotionApiData; +} + +function parsePromotion(raw: Object | undefined): Promotion { + const obj: PromotionRaw = (raw as PromotionRaw) ?? {}; + const title: string = obj.title ?? obj.name ?? DEFAULT_PROMOTION.title; + const image: string = obj.image ?? obj.pic_url ?? DEFAULT_PROMOTION.image; + const endVal: number | string | undefined = obj.end_time ?? obj.endTime; + const endTime: number = endVal !== undefined ? Number(endVal) : DEFAULT_PROMOTION.endTime; + const link: string = obj.link ?? obj.url ?? obj.wap_url ?? DEFAULT_PROMOTION.link; + return { title, image, endTime, link }; +} + +export async function getCachedPromotion(context: common.Context): Promise { + try { + const prefs: preferences.Preferences = await getPreferences(context); + const cached: string = await prefs.get(CACHE_KEY_PROMO, '') as string; + if (!cached) { + return null; + } + const parsed: CachedPromotion = JSON.parse(cached) as CachedPromotion; + if (!parsed || typeof parsed._cacheTime !== 'number' || !parsed.data) { + return null; + } + if (Date.now() - parsed._cacheTime > CACHE_TTL_MS) { + return null; + } + return parsed.data; + } catch (e) { + return null; + } +} + +async function writeCache(context: common.Context, info: Promotion): Promise { + try { + const prefs: preferences.Preferences = await getPreferences(context); + const payload: CachedPromotion = { _cacheTime: Date.now(), data: info }; + await prefs.put(CACHE_KEY_PROMO, JSON.stringify(payload)); + await prefs.flush(); + } catch (e) { + // 缓存失败不阻塞主流程 + } +} + +// 4x4 卡片活动位 +// 接口待用户确认,占位使用与 ProductCardService 相同的 POST form-urlencoded 模式 +// 真实响应结构预计: { code, data: { promotion: { title, image, end_time, link } } } +// 接入时只需替换 PROMOTION_API 常量 +const PROMOTION_API = '/api/promotion/active'; + +export async function fetchPromotion(context: common.Context): Promise { + const httpRequest: http.HttpRequest = http.createHttp(); + try { + const config: SiteConfig = await getSiteConfig(context); + const url: string = buildApiUrl(config, PROMOTION_API); + const body: string = `app_type=h5&uniacid=${config.uniacid}`; + const response: http.HttpResponse = await httpRequest.request(url, { + method: http.RequestMethod.POST, + connectTimeout: 8000, + readTimeout: 8000, + header: { 'Content-Type': 'application/x-www-form-urlencoded' }, + extraData: body + }); + if (response.responseCode !== 200) { + throw new Error(`HTTP ${response.responseCode}`); + } + const resultStr: string = typeof response.result === 'string' + ? response.result + : JSON.stringify(response.result); + const json: PromotionApiResponse = JSON.parse(resultStr) as PromotionApiResponse; + const dataObj: PromotionApiData = json.data ?? {}; + const raw: PromotionRaw = dataObj.promotion ?? dataObj.data ?? dataObj.info ?? {}; + const info: Promotion = parsePromotion(raw as Object); + await writeCache(context, info); + return info; + } catch (e) { + return DEFAULT_PROMOTION; + } finally { + httpRequest.destroy(); + } +} + +export async function getActivePromotion(context: common.Context): Promise { + const cached: Promotion | null = await getCachedPromotion(context); + if (cached) { + return cached; + } + return await fetchPromotion(context); +} diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/common/ShopInfoService.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/common/ShopInfoService.ets index 4da7787..4b53dde 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/ets/common/ShopInfoService.ets +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/common/ShopInfoService.ets @@ -1,7 +1,7 @@ import { preferences } from '@kit.ArkData'; import { http } from '@kit.NetworkKit'; import { common } from '@kit.AbilityKit'; -import { getSiteConfig, buildApiUrl, SHOP_INFO_API, HTTP_TIMEOUT_MS } from './ApiConfig'; +import { getSiteConfig, buildApiUrl, SiteConfig, SHOP_INFO_API, HTTP_TIMEOUT_MS } from './ApiConfig'; const PREFERENCES_NAME = 'shop_card_cache'; const CACHE_KEY_SHOP = 'shop_info_v1'; @@ -39,28 +39,50 @@ interface CachedShopInfo { data: ShopInfo; } -function parseShopInfo(raw: Object | undefined): ShopInfo { - if (!raw || typeof raw !== 'object') { - return DEFAULT_SHOP_INFO; - } - const obj = raw as Record; - // 兼容多种字段命名:shop_name / name / site_name - const name: string = String(obj['shop_name'] ?? obj['name'] ?? obj['site_name'] ?? DEFAULT_SHOP_INFO.shopName); - // logo: shop_logo / logo / image - const logo: string = String(obj['shop_logo'] ?? obj['logo'] ?? obj['image'] ?? ''); - // 营业状态:is_open (1=营业, 0=休息) / status ('open'/'closed') +// shop_info / site_info 都能用到的字段(放宽成可选,缺失时是 undefined) +interface ShopInfoRaw { + name?: string; + site_name?: string; + shop_name?: string; + avatar?: string; + logo?: string; + shop_logo?: string; + image?: string; + shop_status?: number | string; + is_open?: number | string; + status?: string; +} + +interface ApiResponseData { + shop_info?: ShopInfoRaw; + site_info?: ShopInfoRaw; +} + +interface ApiResponse { + code?: number; + message?: string; + data?: ApiResponseData; +} + +// 解析 /api/config/init 返回的 shop_info / site_info +// ArkTS 1.1 不允许 obj['key'] 动态访问,所有字段必须用静态属性语法 +function parseShopInfo(shopInfoRaw: Object | undefined, siteInfoRaw: Object | undefined): ShopInfo { + const shopObj: ShopInfoRaw = (shopInfoRaw as ShopInfoRaw) ?? {}; + const siteObj: ShopInfoRaw = (siteInfoRaw as ShopInfoRaw) ?? {}; + + // 店铺名称:优先 shop_info.name,兜底 site_info.site_name + const name: string = shopObj.name ?? siteObj.site_name ?? shopObj.shop_name ?? DEFAULT_SHOP_INFO.shopName; + // logo:优先 site_info.logo(完整URL),兜底 shop_info.avatar + const logo: string = siteObj.logo ?? shopObj.avatar ?? shopObj.shop_logo ?? shopObj.image ?? ''; + // 营业状态:shop_status (1=营业, 0=休息) let status: 'open' | 'closed' = 'open'; - const statusRaw: Object = obj['is_open'] ?? obj['status'] ?? 1; + const statusRaw: number | string | undefined = shopObj.shop_status ?? shopObj.is_open ?? shopObj.status ?? 1; if (statusRaw === 0 || statusRaw === 'closed' || statusRaw === '0') { status = 'closed'; } return { shopName: name, shopLogo: logo, shopStatus: status }; } -interface ApiResponse { - data?: Record; -} - export async function getCachedShopInfo(context: common.Context): Promise { try { const prefs: preferences.Preferences = await getPreferences(context); @@ -95,8 +117,8 @@ async function writeCache(context: common.Context, info: ShopInfo): Promise { const httpRequest: http.HttpRequest = http.createHttp(); try { - const config = await getSiteConfig(context); - const url: string = buildApiUrl(config, SHOP_INFO_API, { uniacid: config.uniacid }); + const config: SiteConfig = await getSiteConfig(context); + const url: string = buildApiUrl(config, SHOP_INFO_API); const response: http.HttpResponse = await httpRequest.request(url, { method: http.RequestMethod.GET, connectTimeout: HTTP_TIMEOUT_MS, @@ -110,12 +132,13 @@ export async function fetchShopInfo(context: common.Context): Promise ? response.result : JSON.stringify(response.result); const json: ApiResponse = JSON.parse(resultStr) as ApiResponse; - const shopRaw: Object | undefined = json.data?.['shop_info']; - const info: ShopInfo = parseShopInfo(shopRaw); + const dataObj: ApiResponseData = json.data ?? {}; + const shopRaw: ShopInfoRaw | undefined = dataObj.shop_info; + const siteRaw: ShopInfoRaw | undefined = dataObj.site_info; + const info: ShopInfo = parseShopInfo(shopRaw, siteRaw); await writeCache(context, info); return info; } catch (e) { - // 拉取失败返回默认值,让卡片至少可显示 return DEFAULT_SHOP_INFO; } finally { httpRequest.destroy(); diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/entryformability/EntryFormAbility.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/entryformability/EntryFormAbility.ets index b20661a..cf5b248 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/ets/entryformability/EntryFormAbility.ets +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/entryformability/EntryFormAbility.ets @@ -1,23 +1,68 @@ import { formBindingData, formProvider, FormExtensionAbility, formInfo } from '@kit.FormKit'; import { Want } from '@kit.AbilityKit'; import { getShopInfo, ShopInfo } from '../common/ShopInfoService'; +import { getProductCards, ProductItem } from '../common/ProductCardService'; +import { getActivePromotion, Promotion } from '../common/PromotionService'; -interface CardBinding { +const FORM_DIM_2X2 = '2*2'; +const FORM_DIM_2X4 = '2*4'; +const FORM_DIM_4X4 = '4*4'; + +interface ShopCardData { + cardType: string; shopName: string; shopLogo: string; shopStatus: string; } +interface ProductCardData { + cardType: string; + products: ProductItem[]; +} + +interface PromoCardData { + cardType: string; + title: string; + image: string; + endTime: number; + link: string; +} + +const formDimensions: Map = new Map(); + export default class EntryFormAbility extends FormExtensionAbility { onAddForm(want: Want) { - // 同步返回初始 bindingData,卡片先显示 fallback - // 真实数据由 onUpdateForm 异步拉取后通过 formProvider.updateForm 推送刷新 - const initial: CardBinding = { - shopName: '店铺', - shopLogo: '', - shopStatus: 'open' + // want.parameters 是 Record,ArkTS 1.1 不允许 obj['key'] 动态索引 + // 通过 Record 强转使字段被识别为索引签名,再 bracket 访问 + const params: Record = want.parameters as Record; + const dimension: string = params['ohos.extra.param.key.form_dimension'] ?? FORM_DIM_2X2; + const formId: string = params['ohos.extra.param.key.form_identity'] ?? ''; + formDimensions.set(formId, dimension); + + if (dimension === FORM_DIM_2X2) { + const data: ShopCardData = { + cardType: 'shop', + shopName: '店铺', + shopLogo: '', + shopStatus: 'open' + }; + return formBindingData.createFormBindingData(data); + } + if (dimension === FORM_DIM_2X4) { + const data: ProductCardData = { + cardType: 'product', + products: [] + }; + return formBindingData.createFormBindingData(data); + } + const data: PromoCardData = { + cardType: 'promo', + title: '限时活动', + image: '', + endTime: 0, + link: '' }; - return formBindingData.createFormBindingData(initial); + return formBindingData.createFormBindingData(data); } onCastToNormalForm(formId: string) { @@ -25,7 +70,6 @@ export default class EntryFormAbility extends FormExtensionAbility { } onUpdateForm(formId: string) { - // 异步拉取店铺信息并推送到卡片 this.refreshCard(formId); } @@ -34,7 +78,7 @@ export default class EntryFormAbility extends FormExtensionAbility { } onRemoveForm(formId: string) { - // 卡片被移除(预留) + formDimensions.delete(formId); } onAcquireFormState(want: Want) { @@ -42,14 +86,34 @@ export default class EntryFormAbility extends FormExtensionAbility { } private async refreshCard(formId: string): Promise { + const dimension: string = formDimensions.get(formId) ?? FORM_DIM_2X2; try { - const info: ShopInfo = await getShopInfo(this.context); - const binding: CardBinding = { - shopName: info.shopName, - shopLogo: info.shopLogo, - shopStatus: info.shopStatus + if (dimension === FORM_DIM_2X2) { + const info: ShopInfo = await getShopInfo(this.context); + const data: ShopCardData = { + cardType: 'shop', + shopName: info.shopName, + shopLogo: info.shopLogo, + shopStatus: info.shopStatus + }; + await formProvider.updateForm(formId, formBindingData.createFormBindingData(data)); + return; + } + if (dimension === FORM_DIM_2X4) { + const products: ProductItem[] = await getProductCards(this.context); + const data: ProductCardData = { cardType: 'product', products }; + await formProvider.updateForm(formId, formBindingData.createFormBindingData(data)); + return; + } + const promo: Promotion = await getActivePromotion(this.context); + const data: PromoCardData = { + cardType: 'promo', + title: promo.title, + image: promo.image, + endTime: promo.endTime, + link: promo.link }; - await formProvider.updateForm(formId, formBindingData.createFormBindingData(binding)); + await formProvider.updateForm(formId, formBindingData.createFormBindingData(data)); } catch (e) { // 拉取失败保持卡片当前内容,等下次刷新 } diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCard.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCard.ets index 7970e58..6b25e84 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCard.ets +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCard.ets @@ -1,74 +1,118 @@ // 卡片 UI:店铺信息卡 (2*2) -// 数据通过 formBindingData 由 EntryFormAbility 注入, +// 数据通过 formBindingData 由 EntryFormAbility 注入 // 卡片通过 @LocalStorageProp 从 Form 框架的 LocalStorage 读取 +// 底部两个快捷动作:分类 / 活动(用户选择) -const storage: LocalStorage = new LocalStorage(); +const storageShop: LocalStorage = new LocalStorage(); -@Entry(storage) +@Entry(storageShop) @Component struct WidgetCard { + @LocalStorageProp('cardType') cardType: string = 'shop'; @LocalStorageProp('shopName') shopName: string = '加载中...'; @LocalStorageProp('shopLogo') shopLogo: string = ''; @LocalStorageProp('shopStatus') shopStatus: string = 'open'; build() { - Row() { - // Logo - if (this.shopLogo) { - Image(this.shopLogo) - .width('56vp') - .height('56vp') - .borderRadius('28vp') - .objectFit(ImageFit.Cover) - .alt($r('app.media.app_icon')) - } else { - Image($r('app.media.app_icon')) - .width('56vp') - .height('56vp') - .borderRadius('28vp') - .objectFit(ImageFit.Cover) - } - - // 名称 + 状态 - Column() { - Text(this.shopName) - .fontSize('14fp') - .fontWeight(FontWeight.Medium) - .fontColor('#182431') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .width('100%') - - Row() { - // 状态圆点 - Circle({ width: '6vp', height: '6vp' }) - .fill(this.shopStatus === 'open' ? '#FF4F14' : '#999999') - - Text(this.shopStatus === 'open' ? '营业中' : '休息中') - .fontSize('10fp') - .fontColor(this.shopStatus === 'open' ? '#FF4F14' : '#999999') - .margin({ left: '4vp' }) + Stack() { + // 顶层 Row:Logo + 名称 + 状态 + Row() { + if (this.shopLogo) { + Image(this.shopLogo) + .width('48vp') + .height('48vp') + .borderRadius('24vp') + .objectFit(ImageFit.Cover) + .alt($r('app.media.app_icon')) + } else { + Image($r('app.media.app_icon')) + .width('48vp') + .height('48vp') + .borderRadius('24vp') + .objectFit(ImageFit.Cover) } - .margin({ top: '6vp' }) + + Column() { + Text(this.shopName) + .fontSize('14fp') + .fontWeight(FontWeight.Medium) + .fontColor('#182431') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + + Row() { + Circle({ width: '6vp', height: '6vp' }) + .fill(this.shopStatus === 'open' ? '#FF4F14' : '#999999') + + Text(this.shopStatus === 'open' ? '营业中' : '休息中') + .fontSize('10fp') + .fontColor(this.shopStatus === 'open' ? '#FF4F14' : '#999999') + .margin({ left: '4vp' }) + } + .margin({ top: '6vp' }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .margin({ left: '10vp' }) } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - .margin({ left: '12vp' }) + .width('100%') + .alignItems(VerticalAlign.Center) + .onClick(() => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { message: 'open_shop_card' } + }); + }) + + // 底部两个快捷动作 + Row() { + this.ActionButton('分类', '\u{e6c4}', '#FF4F14', () => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { message: 'open_category' } + }); + }) + + this.ActionButton('活动', '\u{e6c5}', '#F7B500', () => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { message: 'open_promotion' } + }); + }) + } + .width('100%') + .position({ x: 0, y: '74%' }) + .padding({ left: '12vp', right: '12vp' }) + .justifyContent(FlexAlign.SpaceBetween) } .width('100%') .height('100%') - .padding('12vp') + .padding({ top: '10vp' }) .backgroundColor('#FFFFFF') .borderRadius('16vp') - .alignItems(VerticalAlign.Center) - .onClick(() => { - postCardAction(this, { - action: 'router', - abilityName: 'EntryAbility', - params: { - message: 'open_shop_card' - } - }); - }) + } + + @Builder + ActionButton(label: string, iconChar: string, color: string, onTap: () => void) { + Column() { + Text(iconChar) + .fontSize('20fp') + .fontColor(color) + .width('32vp') + .height('32vp') + .textAlign(TextAlign.Center) + .borderRadius('16vp') + .backgroundColor('#F5F6F8') + Text(label) + .fontSize('9fp') + .fontColor('#666666') + .margin({ top: '2vp' }) + } + .alignItems(HorizontalAlign.Center) + .onClick(onTap) } } diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardFull.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardFull.ets new file mode 100644 index 0000000..d1fd654 --- /dev/null +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardFull.ets @@ -0,0 +1,122 @@ +// 卡片 UI:活动卡片 (4*4) +// 数据通过 formBindingData 由 EntryFormAbility 注入 + +const storagePromo: LocalStorage = new LocalStorage(); + +@Entry(storagePromo) +@Component +struct WidgetCardFull { + @LocalStorageProp('cardType') cardType: string = 'promo'; + @LocalStorageProp('title') title: string = '限时活动'; + @LocalStorageProp('image') image: string = ''; + @LocalStorageProp('endTime') endTime: number = 0; + @LocalStorageProp('link') link: string = ''; + + build() { + Column() { + // 标题行 + Row() { + Text(this.title) + .fontSize('16fp') + .fontWeight(FontWeight.Bold) + .fontColor('#182431') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + this.CountdownChip() + } + .width('100%') + .padding({ left: '12vp', right: '12vp', top: '12vp' }) + .alignItems(VerticalAlign.Center) + + // 大图区 + Stack() { + if (this.image) { + Image(this.image) + .width('100%') + .height('100%') + .objectFit(ImageFit.Cover) + .borderRadius('8vp') + .alt($r('app.media.app_icon')) + } else { + Column() { + Text('活动') + .fontSize('40fp') + .fontColor('#FF4F14') + .fontWeight(FontWeight.Bold) + } + .width('100%') + .height('100%') + .backgroundColor('#FFF1ED') + .borderRadius('8vp') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + } + .width('100%') + .layoutWeight(1) + .padding('12vp') + + // CTA 按钮 + Text('立即查看') + .fontSize('13fp') + .fontColor('#FFFFFF') + .width('100%') + .height('36vp') + .textAlign(TextAlign.Center) + .backgroundColor('#FF4F14') + .borderRadius('18vp') + .margin({ left: '12vp', right: '12vp', bottom: '12vp', top: '4vp' }) + .onClick(() => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { + message: 'open_promotion_detail', + link: this.link + } + }); + }) + } + .width('100%') + .height('100%') + .backgroundColor('#FFFFFF') + .borderRadius('16vp') + .alignItems(HorizontalAlign.Start) + } + + @Builder + CountdownChip() { + Row() { + Text('距结束') + .fontSize('10fp') + .fontColor('#FF4F14') + Text(this.formatCountdown()) + .fontSize('11fp') + .fontColor('#FF4F14') + .fontWeight(FontWeight.Medium) + .margin({ left: '4vp' }) + } + .padding({ left: '8vp', right: '8vp', top: '3vp', bottom: '3vp' }) + .backgroundColor('#FFF1ED') + .borderRadius('10vp') + .visibility(this.endTime > 0 ? Visibility.Visible : Visibility.Hidden) + } + + private formatCountdown(): string { + if (this.endTime <= 0) { + return ''; + } + const remain: number = Math.max(0, Math.floor((this.endTime - Date.now()) / 1000)); + const d: number = Math.floor(remain / 86400); + const h: number = Math.floor((remain % 86400) / 3600); + const m: number = Math.floor((remain % 3600) / 60); + if (d > 0) { + return `${d}天${h}时`; + } + if (h > 0) { + return `${h}时${m}分`; + } + return `${m}分`; + } +} diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardHorizontal.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardHorizontal.ets new file mode 100644 index 0000000..d2ec484 --- /dev/null +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/widget/pages/WidgetCardHorizontal.ets @@ -0,0 +1,108 @@ +// 卡片 UI:商品横滑卡 (2*4) +// 数据通过 formBindingData 由 EntryFormAbility 注入 + +const storageProduct: LocalStorage = new LocalStorage(); + +interface ProductDisplay { + goodsId: string; + goodsName: string; + price: string; + picUrl: string; +} + +@Entry(storageProduct) +@Component +struct WidgetCardHorizontal { + @LocalStorageProp('cardType') cardType: string = 'product'; + @LocalStorageProp('products') products: ProductDisplay[] = []; + + build() { + Column() { + // 标题 + Row() { + Text('推荐商品') + .fontSize('14fp') + .fontWeight(FontWeight.Medium) + .fontColor('#182431') + Text('查看更多') + .fontSize('10fp') + .fontColor('#999999') + .margin({ left: '8vp' }) + } + .width('100%') + .padding({ left: '12vp', right: '12vp', top: '10vp' }) + .justifyContent(FlexAlign.Start) + + // 商品列表 + List({ space: 8 }) { + ForEach(this.products, (item: ProductDisplay) => { + ListItem() { + this.ProductRow(item) + } + }, (item: ProductDisplay): string => item.goodsId) + } + .width('100%') + .layoutWeight(1) + .padding({ left: '12vp', right: '12vp', top: '8vp' }) + .listDirection(Axis.Vertical) + } + .width('100%') + .height('100%') + .backgroundColor('#FFFFFF') + .borderRadius('16vp') + .alignItems(HorizontalAlign.Start) + } + + @Builder + ProductRow(item: ProductDisplay) { + Row() { + if (item.picUrl) { + Image(item.picUrl) + .width('56vp') + .height('56vp') + .borderRadius('8vp') + .objectFit(ImageFit.Cover) + .alt($r('app.media.app_icon')) + } else { + Image($r('app.media.app_icon')) + .width('56vp') + .height('56vp') + .borderRadius('8vp') + .objectFit(ImageFit.Cover) + } + + Column() { + Text(item.goodsName) + .fontSize('12fp') + .fontColor('#333333') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + + Text(`¥${item.price}`) + .fontSize('13fp') + .fontColor('#FF4F14') + .fontWeight(FontWeight.Medium) + .margin({ top: '6vp' }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .margin({ left: '10vp' }) + } + .width('100%') + .padding('8vp') + .backgroundColor('#F8F8F8') + .borderRadius('8vp') + .alignItems(VerticalAlign.Center) + .onClick(() => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { + message: 'open_goods_detail', + goods_id: item.goodsId + } + }); + }) + } +}