feat: 新增多尺寸卡片组件与配套服务

新增2*4商品横滑卡片、4*4活动促销卡片,重构基础店铺卡片UI;添加商品、活动数据拉取缓存服务,修复TextDecoder兼容问题,优化API拼接逻辑,支持多卡片尺寸适配
This commit is contained in:
2026-07-08 11:48:31 +08:00
parent 7808c63a79
commit fd4fd61399
8 changed files with 806 additions and 105 deletions

View File

@@ -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<SiteConfig
}
try {
const data: Uint8Array = await context.resourceManager.getRawFileContent('site.js');
const text: string = new TextDecoder().decode(data);
const text: string = bytesToString(data);
cachedConfig = parseSiteJs(text);
} catch (e) {
cachedConfig = FALLBACK_CONFIG;
@@ -41,25 +71,20 @@ export async function getSiteConfig(context: common.Context): Promise<SiteConfig
return cachedConfig;
}
// 拼接 URL:处理 baseUrl 末尾斜杠、path 起头斜杠避免双斜杠
// query 中 uniacid 等参数统一走 URLSearchParams 编码
export function buildApiUrl(config: SiteConfig, path: string, query?: Record<string, string | number>): 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 超时(毫秒)

View File

@@ -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<preferences.Preferences> {
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<ProductItem[] | null> {
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<void> {
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<ProductItem[]> {
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<ProductItem[]> {
const cached: ProductItem[] | null = await getCachedProducts(context);
if (cached && cached.length > 0) {
return cached;
}
return await fetchProducts(context);
}

View File

@@ -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<preferences.Preferences> {
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<Promotion | null> {
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<void> {
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<Promotion> {
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<Promotion> {
const cached: Promotion | null = await getCachedPromotion(context);
if (cached) {
return cached;
}
return await fetchPromotion(context);
}

View File

@@ -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<string, Object>;
// 兼容多种字段命名: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<string, Object>;
}
export async function getCachedShopInfo(context: common.Context): Promise<ShopInfo | null> {
try {
const prefs: preferences.Preferences = await getPreferences(context);
@@ -95,8 +117,8 @@ async function writeCache(context: common.Context, info: ShopInfo): Promise<void
export async function fetchShopInfo(context: common.Context): Promise<ShopInfo> {
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<ShopInfo>
? 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();

View File

@@ -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<string, string> = 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<string, Object>,ArkTS 1.1 不允许 obj['key'] 动态索引
// 通过 Record<string, string> 强转使字段被识别为索引签名,再 bracket 访问
const params: Record<string, string> = want.parameters as Record<string, string>;
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<void> {
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) {
// 拉取失败保持卡片当前内容,等下次刷新
}

View File

@@ -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)
}
}

View File

@@ -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}分`;
}
}

View File

@@ -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
}
});
})
}
}