feat(harmony): 实现鸿蒙卡片的店铺信息展示功能

1. 新增harmony.patch.js脚本,将根目录site.js同步到鸿蒙项目rawfile目录,供FormAbility读取配置
2. 新增ApiConfig.ets,实现从rawfile读取站点配置并兜底降级
3. 新增ShopInfoService.ets,封装店铺信息缓存、API请求和数据解析逻辑
4. 重构EntryFormAbility.ets,添加卡片异步更新和数据渲染逻辑
5. 重构WidgetCard.ets,实现带店铺logo、名称和营业状态的卡片UI
This commit is contained in:
2026-07-07 18:16:18 +08:00
parent 82fd3839a9
commit 7808c63a79
5 changed files with 332 additions and 82 deletions

View File

@@ -51,6 +51,10 @@ async function copyAssets(mode = 'production') {
const srcSitePath = path.join(cwd, 'site.js');
if (await exists(srcSitePath)) {
copyPromises.push(fsp.copyFile(srcSitePath, path.join(destDir, 'site.js')));
// 同步一份到 entry rawfile,让 FormAbility 能在运行时 getRawFileContent('site.js') 读取
// (FormAbility 进程读不到 ascf_src/ 那份 webpack bundle,需要 rawfile 副本)
const rawfileSitePath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'entry', 'src', 'main', 'resources', 'rawfile', 'site.js');
copyPromises.push(fsp.copyFile(srcSitePath, rawfileSitePath));
}
await Promise.all(copyPromises);

View File

@@ -0,0 +1,66 @@
// 店铺信息 API 配置
// 基础参数(baseUrl / uniacid)运行时从 rawfile 'site.js' 读取,与 H5/uni-app 端保持同源
// site.js 由 scripts/harmony.patch.js 从工程根目录的 site.js 同步而来
// 读取失败时使用 FALLBACK_CONFIG 兜底
import { common } from '@kit.AbilityKit';
export interface SiteConfig {
baseUrl: string;
uniacid: number;
}
const FALLBACK_CONFIG: SiteConfig = {
baseUrl: 'https://xcx30.5g-quickapp.com',
uniacid: 0
};
let cachedConfig: SiteConfig | null = null;
// 解析 site.js (CommonJS 风格: module.exports = { baseUrl: "x", uniacid: y })
// 不做 eval/Function 构造,仅用正则提取
function parseSiteJs(text: string): SiteConfig {
const baseUrlMatch: RegExpMatchArray | null = text.match(/baseUrl\s*:\s*["']([^"']+)["']/);
const uniacidMatch: RegExpMatchArray | null = text.match(/uniacid\s*:\s*(\d+)/);
const baseUrl: string = baseUrlMatch?.[1] ?? FALLBACK_CONFIG.baseUrl;
const uniacid: number = uniacidMatch?.[1] ? Number.parseInt(uniacidMatch[1], 10) : FALLBACK_CONFIG.uniacid;
return { baseUrl, uniacid };
}
export async function getSiteConfig(context: common.Context): Promise<SiteConfig> {
if (cachedConfig) {
return cachedConfig;
}
try {
const data: Uint8Array = await context.resourceManager.getRawFileContent('site.js');
const text: string = new TextDecoder().decode(data);
cachedConfig = parseSiteJs(text);
} catch (e) {
cachedConfig = FALLBACK_CONFIG;
}
return cachedConfig;
}
// 拼接 URL:处理 baseUrl 末尾斜杠、path 起头斜杠避免双斜杠
// query 中 uniacid 等参数统一走 URLSearchParams 编码
export function buildApiUrl(config: SiteConfig, path: string, query?: Record<string, string | number>): 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('&')}`;
}
return url;
}
// 店铺信息接口:H5/uni-app 启动时也会调用此接口
// 响应结构: { code, data: { shop_info: { ... } } }
export const SHOP_INFO_API: string = '/api/config/init';
// HTTP 超时(毫秒)
export const HTTP_TIMEOUT_MS: number = 8000;

View File

@@ -0,0 +1,131 @@
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';
const PREFERENCES_NAME = 'shop_card_cache';
const CACHE_KEY_SHOP = 'shop_info_v1';
const CACHE_TTL_MS = 30 * 60 * 1000;
export interface ShopInfo {
shopName: string;
shopLogo: string;
shopStatus: 'open' | 'closed';
}
const DEFAULT_SHOP_INFO: ShopInfo = {
shopName: '店铺',
shopLogo: '',
shopStatus: 'open'
};
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 CachedShopInfo {
_cacheTime: number;
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')
let status: 'open' | 'closed' = 'open';
const statusRaw: Object = obj['is_open'] ?? obj['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);
const cached: string = await prefs.get(CACHE_KEY_SHOP, '') as string;
if (!cached) {
return null;
}
const parsed: CachedShopInfo = JSON.parse(cached) as CachedShopInfo;
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: ShopInfo): Promise<void> {
try {
const prefs: preferences.Preferences = await getPreferences(context);
const payload: CachedShopInfo = { _cacheTime: Date.now(), data: info };
await prefs.put(CACHE_KEY_SHOP, JSON.stringify(payload));
await prefs.flush();
} catch (e) {
// 缓存失败不阻塞主流程
}
}
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 response: http.HttpResponse = await httpRequest.request(url, {
method: http.RequestMethod.GET,
connectTimeout: HTTP_TIMEOUT_MS,
readTimeout: HTTP_TIMEOUT_MS,
header: { 'Content-Type': 'application/json' }
});
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: ApiResponse = JSON.parse(resultStr) as ApiResponse;
const shopRaw: Object | undefined = json.data?.['shop_info'];
const info: ShopInfo = parseShopInfo(shopRaw);
await writeCache(context, info);
return info;
} catch (e) {
// 拉取失败返回默认值,让卡片至少可显示
return DEFAULT_SHOP_INFO;
} finally {
httpRequest.destroy();
}
}
export async function getShopInfo(context: common.Context): Promise<ShopInfo> {
const cached: ShopInfo | null = await getCachedShopInfo(context);
if (cached) {
return cached;
}
return await fetchShopInfo(context);
}

View File

@@ -1,32 +1,57 @@
import { formBindingData, FormExtensionAbility, formInfo } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
export default class EntryFormAbility extends FormExtensionAbility {
onAddForm(want: Want) {
// Called to return a FormBindingData object.
const formData = '';
return formBindingData.createFormBindingData(formData);
}
onCastToNormalForm(formId: string) {
// Called when the form provider is notified that a temporary form is successfully
// converted to a normal form.
}
onUpdateForm(formId: string) {
// Called to notify the form provider to update a specified form.
}
onFormEvent(formId: string, message: string) {
// Called when a specified message event defined by the form provider is triggered.
}
onRemoveForm(formId: string) {
// Called to notify the form provider that a specified form has been destroyed.
}
onAcquireFormState(want: Want) {
// Called to return a {@link FormState} object.
return formInfo.FormState.READY;
}
}
import { formBindingData, formProvider, FormExtensionAbility, formInfo } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { getShopInfo, ShopInfo } from '../common/ShopInfoService';
interface CardBinding {
shopName: string;
shopLogo: string;
shopStatus: string;
}
export default class EntryFormAbility extends FormExtensionAbility {
onAddForm(want: Want) {
// 同步返回初始 bindingData,卡片先显示 fallback
// 真实数据由 onUpdateForm 异步拉取后通过 formProvider.updateForm 推送刷新
const initial: CardBinding = {
shopName: '店铺',
shopLogo: '',
shopStatus: 'open'
};
return formBindingData.createFormBindingData(initial);
}
onCastToNormalForm(formId: string) {
// 临时卡片转正时触发,无需特殊处理
}
onUpdateForm(formId: string) {
// 异步拉取店铺信息并推送到卡片
this.refreshCard(formId);
}
onFormEvent(formId: string, message: string) {
// 卡片内事件(预留)
}
onRemoveForm(formId: string) {
// 卡片被移除(预留)
}
onAcquireFormState(want: Want) {
return formInfo.FormState.READY;
}
private async refreshCard(formId: string): Promise<void> {
try {
const info: ShopInfo = await getShopInfo(this.context);
const binding: CardBinding = {
shopName: info.shopName,
shopLogo: info.shopLogo,
shopStatus: info.shopStatus
};
await formProvider.updateForm(formId, formBindingData.createFormBindingData(binding));
} catch (e) {
// 拉取失败保持卡片当前内容,等下次刷新
}
}
}

View File

@@ -1,50 +1,74 @@
@Entry
@Component
struct WidgetCard {
/*
* The title.
*/
readonly title: string = '茶文化生顺茶栈';
/*
* The action type.
*/
readonly actionType: string = 'router';
/*
* The ability name.
*/
readonly abilityName: string = 'EntryAbility';
/*
* The message.
*/
readonly message: string = 'add detail';
/*
* The width percentage setting.
*/
readonly fullWidthPercent: string = '100%';
/*
* The height percentage setting.
*/
readonly fullHeightPercent: string = '100%';
build() {
Row() {
Column() {
Text(this.title)
.fontSize($r('app.float.font_size'))
.fontWeight(FontWeight.Medium)
.fontColor($r('sys.color.font_primary'))
}
.width(this.fullWidthPercent)
}
.height(this.fullHeightPercent)
.onClick(() => {
postCardAction(this, {
action: this.actionType,
abilityName: this.abilityName,
params: {
message: this.message
}
});
})
}
}
// 卡片 UI:店铺信息卡 (2*2)
// 数据通过 formBindingData 由 EntryFormAbility 注入,
// 卡片通过 @LocalStorageProp 从 Form 框架的 LocalStorage 读取
const storage: LocalStorage = new LocalStorage();
@Entry(storage)
@Component
struct WidgetCard {
@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' })
}
.margin({ top: '6vp' })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: '12vp' })
}
.width('100%')
.height('100%')
.padding('12vp')
.backgroundColor('#FFFFFF')
.borderRadius('16vp')
.alignItems(VerticalAlign.Center)
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: {
message: 'open_shop_card'
}
});
})
}
}