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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
// 拉取失败保持卡片当前内容,等下次刷新
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user