feat: 新增多尺寸卡片组件与配套服务
新增2*4商品横滑卡片、4*4活动促销卡片,重构基础店铺卡片UI;添加商品、活动数据拉取缓存服务,修复TextDecoder兼容问题,优化API拼接逻辑,支持多卡片尺寸适配
This commit is contained in:
@@ -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 超时(毫秒)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
// 拉取失败保持卡片当前内容,等下次刷新
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}分`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user