HarmonyOS ArkTS 实战:实现一个校园智能存包柜应用
HarmonyOS ArkTS 实战:从零实现校园智能存包柜应用
目录
- 一、项目背景与效果预览
- 二、技术栈与开发环境
- 三、需求分析与功能架构
- 四、数据结构与服务层设计
- 五、核心功能实现(完整代码)
- 六、UI 界面设计与实现(完整组件)
- 七、完整主页面代码(Index.ets)
- 八、运行与调试
- 九、项目总结与扩展思路
一、项目背景与效果预览
1.1 痛点场景
“图书馆占座、体育馆物品无处放、超市购物临时寄存……”校园内存包需求旺盛,但传统存包柜存在找柜难、状态不明、超时罚款无提醒、取包排队等问题。智能存包柜应用可实时查看各柜格状态,在线存/取、预约、扫码开柜,并自动计算费用、提醒超时,极大提升校园资源利用率。
1.2 运行效果(模拟器截图描述)
- 主界面:顶部为统计卡片(累计使用次数、总花费、当前占用柜数);中部为水平滚动的位置切换标签(图书馆一楼、体育馆入口等);下方为柜格网格(4列),每个柜格显示编号和大小,颜色区分状态(绿=空闲、黑=使用中、红=故障、黄=预约中);底部固定显示当前存包状态(若用户正在存包,显示柜号、计时,并带“取包结算”按钮)。
- 交互反馈:点击空闲柜格弹出存包确认弹窗(选择存包时长或确认);点击使用中的自己柜格(若用户ID匹配)可取包,弹出费用明细;扫码取包通过右上角扫码图标模拟(弹出输入框输入柜号);超时提醒在应用后台检测,如果发现超时则弹出通知横幅。
图片占位:实际运行请参考项目附带的演示截图或模拟器运行。
二、技术栈与开发环境
| 技术项 | 说明 |
|---|---|
| 开发语言 | ArkTS |
| UI 框架 | ArkUI 声明式开发范式 |
| 状态管理 | @State / @Observed / @ObjectLink |
| 布局方式 | Grid + Scroll + Row + Column |
| 数据持久化 | @ohos.data.preferences |
| 图表组件 | @ohos.arkui.advanced.Chart |
| 弹窗组件 | @ohos.prompt / @ohos.dialog |
| 定时器 | setTimeout / setInterval |
| 开发工具 | DevEco Studio 5.0+ |
| SDK 版本 | API 24 及以上 |
三、需求分析与功能架构
3.1 核心功能清单(细化)
- 柜格状态总览:按位置分组展示所有柜格,颜色直观显示空闲/使用中/故障/预约中。
- 位置切换:水平滚动选择不同存包点(图书馆、体育馆等),过滤显示对应柜格。
- 在线存包:点击空闲柜格,弹出确认窗口,存入后记录开始时间,并更新状态为“使用中”。
- 取包结算:点击自己正在使用的柜格(或底部状态栏),弹出费用明细(时长×单价),确认后释放柜格,记录历史。
- 扫码取包:模拟扫码(输入柜号),快速取包,适用于用户未在应用界面操作的场景。
- 柜格预约:用户可预约某个空闲柜格(预留30分钟),状态变为“预约中”,超时未存自动释放。
- 超时提醒:使用中柜格若超过预设时长(如24小时),弹出横幅提示并标记为“超时”。
- 存包统计:展示累计使用次数、总花费、平均每次时长等,并以折线图展示近7天使用频次。
- 数据持久化:所有柜格状态、记录、统计数据保存在Preferences,重启不丢失。
3.2 业务流程图(存包为例)
用户点击空闲柜格 → 弹窗确认 → 记录开始时间 → 状态变“使用中” → 底部显示当前存包信息 → 定时器开始计时(用于超时检测)。
四、数据结构与服务层设计
4.1 数据模型定义
// model/Locker.ets
export interface Locker {
id: number;
location: string; // 位置名称
cabinetNumber: string; // 柜号,如A01
size: '小' | '中' | '大';
status: '空闲' | '使用中' | '故障' | '预约中';
userId: string; // 使用人学号/ID,为空表示空闲
startTime: string; // 存包开始时间 ISO字符串
endTime: string; // 取包时间(结算时写入)
pricePerHour: number; // 每小时价格(元)
reservedUntil?: string; // 预约截止时间(预约中时有效)
}
// model/LockerRecord.ets
export interface LockerRecord {
id: number;
lockerId: number;
cabinetNumber: string;
location: string;
size: string;
startTime: string;
endTime: string;
duration: number; // 小时数
totalFee: number;
status: '存包中' | '已取走' | '超时';
}
4.2 服务层(Service)
参考疫苗应用的 BaseService 模式,实现 LockerService 和 RecordService,提供增删改查及持久化。
// service/BaseService.ets
import preferences from '@ohos.data.preferences';
export abstract class BaseService<T> {
protected prefName: string;
protected key: string;
constructor(prefName: string, key: string) {
this.prefName = prefName;
this.key = key;
}
protected async getPreferences(): Promise<preferences.Preferences> {
return await preferences.getPreferences(this.prefName);
}
protected async loadData(): Promise<T[]> {
const pref = await this.getPreferences();
const json = await pref.get(this.key, '[]') as string;
return JSON.parse(json) as T[];
}
protected async saveData(data: T[]): Promise<void> {
const pref = await this.getPreferences();
await pref.put(this.key, JSON.stringify(data));
await pref.flush();
}
abstract fetch(): Promise<T[]>;
abstract add(item: T): Promise<T[]>;
abstract update(id: number, newItem: T): Promise<T[]>;
abstract delete(id: number): Promise<T[]>;
}
// service/LockerService.ets
import { BaseService } from './BaseService';
import { Locker } from '../model/Locker';
class LockerService extends BaseService<Locker> {
constructor() {
super('LockerPrefs', 'lockers');
}
async fetch(): Promise<Locker[]> {
await this.simulateDelay(200);
const data = await this.loadData();
if (data.length === 0) {
const mock = this.getMockData();
await this.saveData(mock);
return mock;
}
return data;
}
async add(item: Locker): Promise<Locker[]> {
const list = await this.loadData();
list.push(item);
await this.saveData(list);
return list;
}
async update(id: number, newItem: Locker): Promise<Locker[]> {
const list = await this.loadData();
const idx = list.findIndex(l => l.id === id);
if (idx !== -1) {
list[idx] = newItem;
await this.saveData(list);
}
return list;
}
async delete(id: number): Promise<Locker[]> {
const list = await this.loadData();
const filtered = list.filter(l => l.id !== id);
await this.saveData(filtered);
return filtered;
}
private getMockData(): Locker[] {
return [
{ id: 1, location: '图书馆一楼', cabinetNumber: 'A01', size: '小', status: '空闲', userId: '', startTime: '', endTime: '', pricePerHour: 1 },
{ id: 2, location: '图书馆一楼', cabinetNumber: 'A02', size: '中', status: '使用中', userId: '2022001', startTime: this.getHoursAgo(2), endTime: '', pricePerHour: 2 },
{ id: 3, location: '图书馆一楼', cabinetNumber: 'A03', size: '大', status: '空闲', userId: '', startTime: '', endTime: '', pricePerHour: 3 },
{ id: 4, location: '图书馆一楼', cabinetNumber: 'A04', size: '小', status: '故障', userId: '', startTime: '', endTime: '', pricePerHour: 1 },
{ id: 5, location: '体育馆入口', cabinetNumber: 'B01', size: '中', status: '空闲', userId: '', startTime: '', endTime: '', pricePerHour: 2 },
{ id: 6, location: '体育馆入口', cabinetNumber: 'B02', size: '大', status: '空闲', userId: '', startTime: '', endTime: '', pricePerHour: 3 },
{ id: 7, location: '食堂门口', cabinetNumber: 'C01', size: '小', status: '空闲', userId: '', startTime: '', endTime: '', pricePerHour: 1 },
{ id: 8, location: '食堂门口', cabinetNumber: 'C02', size: '中', status: '使用中', userId: '2022003', startTime: this.getHoursAgo(5), endTime: '', pricePerHour: 2 },
{ id: 9, location: '教学楼大厅', cabinetNumber: 'D01', size: '大', status: '预约中', userId: '2022005', startTime: '', endTime: '', pricePerHour: 3, reservedUntil: this.getHoursAhead(0.5) },
];
}
private getHoursAgo(hours: number): string {
const d = new Date();
d.setHours(d.getHours() - hours);
return d.toISOString();
}
private getHoursAhead(hours: number): string {
const d = new Date();
d.setHours(d.getHours() + hours);
return d.toISOString();
}
private simulateDelay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
export const lockerService = new LockerService();
同理实现 RecordService(存包记录),提供统计方法。篇幅原因,完整代码见项目附件。
五、核心功能实现(完整代码)
5.1 页面状态与数据加载
// pages/Index.ets 部分
import { Locker } from '../model/Locker';
import { LockerRecord } from '../model/LockerRecord';
import { lockerService } from '../service/LockerService';
import { recordService } from '../service/RecordService';
import prompt from '@ohos.prompt';
import router from '@ohos.router';
import { Chart, ChartType } from '@ohos.arkui.advanced';
@Entry
@Component
struct Index {
// ---- 状态 ----
@State lockers: Locker[] = [];
@State records: LockerRecord[] = [];
@State currentLocation: string = '图书馆一楼';
@State myCurrentLocker: Locker | null = null; // 当前用户正在使用的柜格
@State totalUsedTimes: number = 0;
@State totalSpent: number = 0;
@State isLoading: boolean = true;
@State showAlert: boolean = false;
@State alertMessage: string = '';
// 存包弹窗
@State isStoreDialogVisible: boolean = false;
@State selectedLockerId: number = -1;
// 预约弹窗
@State isReserveDialogVisible: boolean = false;
@State reserveLockerId: number = -1;
@State reserveDuration: number = 0.5; // 小时
// 扫码输入
@State scanInput: string = '';
@State isScanDialogVisible: boolean = false;
// 定时器
private timer: number = -1;
aboutToAppear() {
this.loadData();
this.startTimer();
}
aboutToDisappear() {
if (this.timer !== -1) {
clearInterval(this.timer);
}
}
async loadData() {
this.isLoading = true;
try {
this.lockers = await lockerService.fetch();
this.records = await recordService.fetch();
this.calcStats();
this.findMyLocker();
} catch (e) {
prompt.showToast({ message: '数据加载失败' });
} finally {
this.isLoading = false;
}
}
// 查找当前用户的存包(假设用户ID固定为'me')
private findMyLocker() {
this.myCurrentLocker = this.lockers.find(l => l.userId === 'me' && l.status === '使用中') || null;
}
// 计算统计
private calcStats() {
const taken = this.records.filter(r => r.status === '已取走');
this.totalUsedTimes = taken.length;
this.totalSpent = taken.reduce((sum, r) => sum + r.totalFee, 0);
}
// 定时器:每分钟检查超时(超时阈值24小时)
private startTimer() {
this.timer = setInterval(() => {
this.checkOverdue();
}, 60000); // 每分钟检查
}
private checkOverdue() {
const now = new Date().getTime();
let updated = false;
this.lockers = this.lockers.map(l => {
if (l.status === '使用中' && l.startTime) {
const start = new Date(l.startTime).getTime();
const hours = (now - start) / (1000 * 60 * 60);
if (hours > 24) {
updated = true;
// 标记为超时,但保持占用状态,可后续提醒
// 我们会在UI中显示红色边框提示
// 同时显示横幅
this.alertMessage = `柜号 ${l.cabinetNumber} 已超时,请尽快取包!`;
this.showAlert = true;
setTimeout(() => { this.showAlert = false; }, 8000);
// 也可在此自动释放?但业务上通常不自动释放,仅提醒
}
}
return l;
});
if (updated) {
// 持久化更新
lockerService.fetch().then(() => { /* 忽略 */ });
}
}
// ... 其他方法
}
5.2 存包开柜(含弹窗选择大小)
点击空闲柜格时,弹出确认对话框,确认后执行存包。
private openStoreDialog(lockerId: number) {
this.selectedLockerId = lockerId;
this.isStoreDialogVisible = true;
}
private async confirmStore() {
const locker = this.lockers.find(l => l.id === this.selectedLockerId);
if (!locker || locker.status !== '空闲') {
prompt.showToast({ message: '该柜格不可用' });
return;
}
const now = new Date().toISOString();
const updatedLocker: Locker = {
...locker,
status: '使用中',
userId: 'me',
startTime: now,
endTime: '',
};
try {
this.lockers = await lockerService.update(locker.id, updatedLocker);
// 创建记录
const record: LockerRecord = {
id: Date.now(),
lockerId: locker.id,
cabinetNumber: locker.cabinetNumber,
location: locker.location,
size: locker.size,
startTime: now,
endTime: '',
duration: 0,
totalFee: 0,
status: '存包中'
};
this.records = await recordService.add(record);
this.myCurrentLocker = updatedLocker;
prompt.showToast({ message: '存包成功!' });
this.isStoreDialogVisible = false;
// 刷新统计
this.calcStats();
} catch (e) {
prompt.showToast({ message: '存包失败' });
}
}
弹窗 UI(在 build 中使用 .dialog):
@Builder StoreDialogContent() {
Column() {
Text('确认存包').fontSize(18).fontWeight(FontWeight.Bold).margin(12);
const locker = this.lockers.find(l => l.id === this.selectedLockerId);
if (locker) {
Text(`柜号:${locker.cabinetNumber}`).margin(4);
Text(`大小:${locker.size}`).margin(4);
Text(`价格:${locker.pricePerHour}元/小时`).margin(4);
}
Row() {
Button('取消').onClick(() => this.isStoreDialogVisible = false).backgroundColor('#999');
Button('确认存包').onClick(() => this.confirmStore()).backgroundColor('#059669').margin({ left: 20 });
}.margin(16);
}
.padding(20)
.width('80%')
.backgroundColor('#FFF')
.borderRadius(16);
}
5.3 取包结算(含费用详情动画)
取包时计算时长和费用,弹出明细确认。
private openTakeDialog() {
if (!this.myCurrentLocker) return;
const locker = this.myCurrentLocker;
const now = new Date();
const start = new Date(locker.startTime);
const duration = Math.ceil((now.getTime() - start.getTime()) / (1000 * 60 * 60));
const fee = duration * locker.pricePerHour;
// 弹窗显示明细
prompt.showDialog({
title: '取包结算',
message: `柜号:${locker.cabinetNumber}\n使用时长:${duration}小时\n费用:${fee}元`,
buttons: [
{ text: '取消', color: '#999' },
{ text: '确认取包', color: '#059669' }
]
}).then(result => {
if (result.index === 1) {
this.confirmTake(locker, duration, fee);
}
});
}
private async confirmTake(locker: Locker, duration: number, fee: number) {
const now = new Date().toISOString();
const updatedLocker: Locker = {
...locker,
status: '空闲',
userId: '',
startTime: '',
endTime: now,
};
try {
this.lockers = await lockerService.update(locker.id, updatedLocker);
// 更新记录
const record = this.records.find(r => r.lockerId === locker.id && r.status === '存包中');
if (record) {
const updatedRecord: LockerRecord = {
...record,
endTime: now,
duration: duration,
totalFee: fee,
status: '已取走'
};
this.records = await recordService.update(record.id, updatedRecord);
}
this.myCurrentLocker = null;
this.totalSpent += fee;
this.totalUsedTimes += 1;
prompt.showToast({ message: '取包成功,费用已结算' });
// 刷新统计
this.calcStats();
} catch (e) {
prompt.showToast({ message: '取包失败' });
}
}
5.4 扫码取包(模拟扫码)
点击右上角扫码图标,弹出输入框输入柜号,快速取包(需验证该柜为用户使用)。
private openScanDialog() {
this.scanInput = '';
this.isScanDialogVisible = true;
}
private async confirmScanTake() {
const cabinetNumber = this.scanInput.trim();
if (!cabinetNumber) {
prompt.showToast({ message: '请输入柜号' });
return;
}
const locker = this.lockers.find(l => l.cabinetNumber === cabinetNumber);
if (!locker) {
prompt.showToast({ message: '未找到该柜号' });
return;
}
if (locker.userId !== 'me') {
prompt.showToast({ message: '该柜不是您存包的' });
return;
}
if (locker.status !== '使用中') {
prompt.showToast({ message: '该柜当前不可取' });
return;
}
this.isScanDialogVisible = false;
// 调用取包流程(与上面相同)
this.openTakeDialog(); // 但这里没有锁对象,需直接调用取包逻辑
// 实际可复用,简化:直接构造取包
const now = new Date();
const start = new Date(locker.startTime);
const duration = Math.ceil((now.getTime() - start.getTime()) / (1000 * 60 * 60));
const fee = duration * locker.pricePerHour;
this.confirmTake(locker, duration, fee);
}
扫码弹窗 UI:
@Builder ScanDialogContent() {
Column() {
Text('扫码取包').fontSize(18).fontWeight(FontWeight.Bold).margin(12);
TextInput({ placeholder: '输入柜号,如A01', text: this.scanInput })
.onChange(v => this.scanInput = v)
.width('80%')
.margin(10);
Row() {
Button('取消').onClick(() => this.isScanDialogVisible = false).backgroundColor('#999');
Button('确认取包').onClick(() => this.confirmScanTake()).backgroundColor('#059669').margin({ left: 20 });
}.margin(16);
}
.padding(20)
.width('80%')
.backgroundColor('#FFF')
.borderRadius(16);
}
5.5 超时提醒(定时器检测)
已在 startTimer 中实现,每分钟检查,若超时则弹出横幅(showAlert)。同时可在柜格上添加红色边框或闪烁效果。
5.6 存包统计(含图表)
在页面顶部或独立区域展示统计卡片,并绘制近7天使用频次折线图。
// 计算近7天每天的使用次数(从records中统计)
private getWeeklyData(): { dates: string[], counts: number[] } {
const result: { [date: string]: number } = {};
const now = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
result[key] = 0;
}
this.records.filter(r => r.status === '已取走').forEach(r => {
const date = r.endTime.slice(0, 10);
if (result[date] !== undefined) {
result[date]++;
}
});
const dates = Object.keys(result).sort();
const counts = dates.map(d => result[d]);
return { dates, counts };
}
// 在build中插入统计卡片
@Builder StatsCard() {
Column() {
Row() {
Column() {
Text('累计使用').fontSize(12).fontColor('#78716C');
Text(`${this.totalUsedTimes}次`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1C1917');
}.layoutWeight(1).alignItems(HorizontalAlign.Center);
Column() {
Text('总花费').fontSize(12).fontColor('#78716C');
Text(`¥${this.totalSpent.toFixed(2)}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1C1917');
}.layoutWeight(1).alignItems(HorizontalAlign.Center);
Column() {
Text('当前占用').fontSize(12).fontColor('#78716C');
Text(`${this.lockers.filter(l => l.status === '使用中').length}个`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1C1917');
}.layoutWeight(1).alignItems(HorizontalAlign.Center);
}
.width('100%')
.padding(12)
.backgroundColor('#F5F5F4')
.borderRadius(12);
// 折线图
if (this.records.length > 0) {
const data = this.getWeeklyData();
Chart({
type: ChartType.Line,
datasets: [{
data: data.counts,
color: '#059669',
strokeWidth: 2,
pointStyle: { shape: 'circle', size: 4 }
}],
options: {
xAxis: { labels: data.dates.map(d => d.slice(5)), color: '#999' },
yAxis: { min: 0, step: 1, color: '#999' }
}
}).width('100%').height(80).margin({ top: 8 });
}
}
.width('90%')
.margin({ top: 12 })
}
5.7 数据持久化(Preferences)
已在 BaseService 中实现,每次修改数据后调用 saveData 并 flush,确保持久化。
六、UI 界面设计与实现(完整组件)
6.1 顶部统计卡片
即上面的 StatsCard,显示累计使用、总花费、当前占用,并附带折线图。
6.2 位置切换滚动条
@Builder LocationScroll() {
Scroll(Axis.Horizontal) {
Row({ space: 10 }) {
ForEach(this.locations, (loc: string) => {
Text(loc)
.fontSize(14)
.fontColor(this.currentLocation === loc ? '#FFF' : '#1C1917')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(this.currentLocation === loc ? '#1C1917' : '#F5F5F4')
.borderRadius(20)
.onClick(() => {
this.currentLocation = loc;
})
})
}
.padding({ left: 4, right: 4 })
}
.scrollBar(BarState.Off)
.width('100%')
.height(44)
.margin({ top: 8 })
}
6.3 柜格网格(含状态颜色与点击交互)
@Builder LockerGrid() {
Grid() {
ForEach(
this.lockers.filter(l => l.location === this.currentLocation),
(locker: Locker) => {
GridItem() {
Column() {
Text(locker.cabinetNumber)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(
locker.status === '空闲' ? '#059669' :
locker.status === '使用中' ? '#FFF' :
locker.status === '故障' ? '#DC2626' :
'#CA8A04' // 预约中
)
Text(locker.size)
.fontSize(12)
.fontColor(
locker.status === '空闲' ? '#059669' :
locker.status === '使用中' ? '#94A3B8' :
locker.status === '故障' ? '#DC2626' :
'#CA8A04'
)
.margin({ top: 2 })
// 若超时显示标记
if (locker.status === '使用中' && this.isOverdue(locker)) {
Text('超时')
.fontSize(10)
.fontColor('#DC2626')
.backgroundColor('#FEE2E2')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.borderRadius(4)
.margin({ top: 2 })
}
}
.width(70)
.height(70)
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.backgroundColor(
locker.status === '空闲' ? '#D1FAE5' :
locker.status === '使用中' ? '#1C1917' :
locker.status === '故障' ? '#FEE2E2' :
'#FEF3C7' // 预约中
)
.border({
width: this.isOverdue(locker) ? 2 : 0,
color: '#DC2626'
})
.onClick(() => {
if (locker.status === '空闲') {
this.openStoreDialog(locker.id);
} else if (locker.status === '使用中' && locker.userId === 'me') {
// 自己的柜,取包
this.openTakeDialog();
} else if (locker.status === '预约中' && locker.userId === 'me') {
prompt.showToast({ message: '您已预约此柜,请尽快使用' });
} else {
prompt.showToast({ message: `该柜${locker.status},暂不可操作` });
}
})
}
}
)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.width('100%')
.padding(16)
.layoutWeight(1)
}
辅助方法 isOverdue:
private isOverdue(locker: Locker): boolean {
if (locker.status !== '使用中' || !locker.startTime) return false;
const now = Date.now();
const start = new Date(locker.startTime).getTime();
return (now - start) > 24 * 60 * 60 * 1000;
}
6.4 底部当前存包状态(含取包按钮)
@Builder CurrentStatusBar() {
if (this.myCurrentLocker) {
Row() {
Column({ space: 4 }) {
Text(`存包中:${this.myCurrentLocker.cabinetNumber} (${this.myCurrentLocker.location})`)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#FFF')
Text(`开始:${this.myCurrentLocker.startTime.slice(0, 16)}`)
.fontSize(11)
.fontColor('#A8A29E')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Button('取包结算')
.fontSize(14)
.backgroundColor('#059669')
.height(36)
.onClick(() => this.openTakeDialog())
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 12 })
.backgroundColor('#1C1917')
.margin({ bottom: 0 })
}
}
6.5 预约功能弹窗
在柜格上增加“预约”操作(长按或额外按钮),此处简化:点击空闲柜格时,除存包外可长按预约。由于篇幅,仅展示核心逻辑,可在完整代码中实现。
// 长按事件触发预约弹窗
.gesture(
LongPressGesture({ repeat: false })
.onAction(() => {
if (locker.status === '空闲') {
this.openReserveDialog(locker.id);
}
})
)
private openReserveDialog(lockerId: number) {
this.reserveLockerId = lockerId;
this.isReserveDialogVisible = true;
}
private async confirmReserve() {
const locker = this.lockers.find(l => l.id === this.reserveLockerId);
if (!locker || locker.status !== '空闲') return;
const reservedUntil = new Date();
reservedUntil.setHours(reservedUntil.getHours() + this.reserveDuration);
const updated: Locker = {
...locker,
status: '预约中',
userId: 'me',
reservedUntil: reservedUntil.toISOString()
};
try {
this.lockers = await lockerService.update(locker.id, updated);
prompt.showToast({ message: `预约成功,请在${this.reserveDuration}小时内使用` });
this.isReserveDialogVisible = false;
} catch (e) {
prompt.showToast({ message: '预约失败' });
}
}
七、完整主页面代码(Index.ets)
由于篇幅,此处给出完整骨架,包含所有Builder和业务逻辑,可直接复制到项目中运行(需补全服务层文件)。
// Index.ets
import { Locker } from '../model/Locker';
import { LockerRecord } from '../model/LockerRecord';
import { lockerService } from '../service/LockerService';
import { recordService } from '../service/RecordService';
import prompt from '@ohos.prompt';
import { Chart, ChartType } from '@ohos.arkui.advanced';
@Entry
@Component
struct Index {
// ---- 状态 ----
@State lockers: Locker[] = [];
@State records: LockerRecord[] = [];
@State currentLocation: string = '图书馆一楼';
@State myCurrentLocker: Locker | null = null;
@State totalUsedTimes: number = 0;
@State totalSpent: number = 0;
@State isLoading: boolean = true;
@State showAlert: boolean = false;
@State alertMessage: string = '';
@State isStoreDialogVisible: boolean = false;
@State selectedLockerId: number = -1;
@State isScanDialogVisible: boolean = false;
@State scanInput: string = '';
@State isReserveDialogVisible: boolean = false;
@State reserveLockerId: number = -1;
@State reserveDuration: number = 0.5;
private timer: number = -1;
private locations: string[] = ['图书馆一楼', '体育馆入口', '食堂门口', '教学楼大厅'];
aboutToAppear() {
this.loadData();
this.startTimer();
}
aboutToDisappear() { if (this.timer !== -1) clearInterval(this.timer); }
// 数据加载、统计、查找自己的柜、超时检测、存包、取包、扫码、预约等方法(参考前文)
build() {
Column() {
if (this.isLoading) {
LoadingProgress().width(60).height(60).color('#059669');
} else {
// 横幅提醒
if (this.showAlert) {
Row() {
Text('⚠️ ' + this.alertMessage).fontColor('#FFF').fontSize(14);
}
.width('100%')
.padding(10)
.backgroundColor('#DC2626')
.animation({ duration: 300 });
}
// 统计卡片
this.StatsCard();
// 位置切换
this.LocationScroll();
// 柜格网格
this.LockerGrid();
// 底部状态栏
this.CurrentStatusBar();
}
}
.width('100%')
.height('100%')
.backgroundColor('#FFF')
.dialog($$this.isStoreDialogVisible, this.StoreDialogContent())
.dialog($$this.isScanDialogVisible, this.ScanDialogContent())
.dialog($$this.isReserveDialogVisible, this.ReserveDialogContent())
}
// 所有Builder定义...
}
注意:
dialog是 ArkTS 的声明式对话框,需要 API 24 以上。若版本不支持,可用prompt.showDialog替代。
八、运行与调试
8.1 环境配置
- DevEco Studio 5.0+,SDK API 24。
- 无需额外权限,但若使用震动需申请
ohos.permission.VIBRATE(本项目未用)。 - 图表组件内置,无需额外依赖。
8.2 运行步骤
- 创建项目,将上述完整代码及服务层文件放入相应目录。
- 同步并运行模拟器。
- 测试交互:点击空闲柜→存包;底部取包;扫码取包;长按预约;观察超时提醒(可修改系统时间模拟)。
8.3 常见问题
| 问题 | 解决 |
|---|---|
dialog 不显示 |
使用 prompt.showDialog 替代,或检查 API 版本 |
| 数据不持久化 | 确认 preferences.flush() 被调用 |
| 图表不显示 | 检查数据长度,确保 datasets 格式正确 |
| 长按事件与点击冲突 | 使用 gesture 和 onClick 组合,或调整优先级 |
九、项目总结与扩展思路
9.1 项目总结
本应用实现了一个功能完备的校园智能存包柜系统,核心收获:
- 网格布局:使用
Grid高效展示多状态柜格。 - 状态管理:通过
@State管理柜格列表,实时更新。 - 定时任务:利用
setInterval实现超时自动检测。 - 数据持久化:通过
Preferences保存所有状态,避免数据丢失。 - 交互反馈:弹窗确认、Toast提示、颜色区分,提升用户体验。
9.2 扩展方向
- 真实扫码:集成
@ohos.multimodalInput.scanner实现摄像头扫码。 - 人脸识别:对接人脸识别SDK,实现无感取包。
- 物联网集成:与智能存包柜硬件通信,远程开锁。
- 支付功能:接入微信/支付宝支付,在线付费。
- 消息推送:超时提醒、预约到期通过
@ohos.notification推送。 - 历史记录查询:支持按日期筛选、导出。
运行效果

更多推荐


所有评论(0)