HarmonyOS NEXT 实战开发:实现日常提醒应用

为什么要开发这个日常提醒应用?

  • 最近鸿蒙热度一直不减,而且前端的就业环境越来越差,所以心里面萌生了换一个赛道的想法。
  • HarmonyOS NEXT 是华为打造的国产之光,而且是纯血版不再是套壳,更加激起了我的好奇心。
  • ArkTS是HarmonyOS优选的主力应用开发语言。ArkTS围绕应用开发在TypeScript(简称TS)生态基础上做了进一步扩展,继承了TS的所有特性,是TS的超集。所以对于我们前端开发来说非常友好。
  • HarmonyOS NEXT 文档也比较齐全。而且官方也有相关示例极大的方便了开发。
  • 根据文档以及自己之前开发经验做一个日常提醒demo 加深自己对HarmonyOS NEXT的理解和实际应用。

日常提醒主要包含功能有哪些?

  • 首页和个人中心tab页切换,以及tab 底部自定义实现。

  • 封装公共弹窗日期选择、列表选择等组件。

  • 访问本地用户首选项preferences实现数据本地化持久存储。

  • 实现后台任务reminderAgentManager提醒。

  • 提醒列表展示,以及删除等。

  • 新增编辑日常提醒记录。

1.实现首页自定义tab页切换

主要依据tab组件以及tab 组件的BottomTabBarStyle的构造函数。

首页page/MinePage.ets代码如下

import {HomeTabs} from  "./components/TabHome"
import {UserBaseInfo} from  "./components/UserBaseInfo"


@Entry
@Component
struct MinePage {
  @State currentIndex: number = 0

  // 构造类 自定义底部切换按钮
  @Builder TabBuilder(index: number,icon:Resource,selectedIcon:Resource,name:string) {
    Column() {
      Image(this.currentIndex === index ? selectedIcon : icon)
        .width(24)
        .height(24)
        .margin({ bottom: 4 })
        .objectFit(ImageFit.Contain)
      Text(`${name}`)
        .fontColor(this.currentIndex === index ? '#007DFF' : '#000000')
        .fontSize('14vp')
        .fontWeight(500)
        .lineHeight(14)
    }.width('100%').height('100%')
    .backgroundColor('#ffffff')
  }
  build() {

    Column() {
      Tabs({ barPosition: BarPosition.End }) {
        TabContent() {
          HomeTabs(); //首页
        }.tabBar(this.TabBuilder(0,$r('app.media.ic_home'),$r('app.media.ic_home_selected'),'首页'))

        TabContent() {
          UserBaseInfo()//个人中心
        }.tabBar(this.TabBuilder(1,$r('app.media.ic_mine'),$r('app.media.ic_mine_selected'),'我的'))
      }
      .vertical(false)
      .scrollable(true)
      .barMode(BarMode.Fixed)
      .onChange((index: number) => {
        this.currentIndex = index;
      })
      .width('100%')
    }
    .width('100%')
    .height('100%')

    .backgroundColor('#f7f7f7')
  }
}

2.封装公共弹窗组件

在ets/common/utils 目录下新建 CommonUtils.ets 文件

import CommonConstants from '../constants/CommonConstants';

/**
 * This is a pop-up window tool class, which is used to encapsulate dialog code.
 * Developers can directly invoke the methods in.
 */
export class CommonUtils {
  /**
   * 确认取消弹窗
   */
  alertDialog(content:{message:string},Callback: Function) {
    AlertDialog.show({
      message: content.message,
      alignment: DialogAlignment.Bottom,
      offset: {
        dx: 0,
        dy: CommonConstants.DY_OFFSET
      },
      primaryButton: {
        value: '取消',
        action: () => {
          Callback({
            type:1
          })
        }
      },
      secondaryButton: {
        value: '确认',
        action: () => {
          Callback({
            type:2
          })
        }
      }
    });
  }


  /**
   * 日期选择
   */
  datePickerDialog(dateCallback: Function) {
    DatePickerDialog.show({
      start: new Date(),
      end: new Date(CommonConstants.END_TIME),
      selected: new Date(CommonConstants.SELECT_TIME),
      lunar: false,
      onAccept: (value: DatePickerResult) => {
        let year: number = Number(value.year);
        let month: number = Number(value.month) + CommonConstants.PLUS_ONE;
        let day: number = Number(value.day);
        let birthdate: string = `${year}-${this.padZero(month)}-${this.padZero(day)}`
        dateCallback(birthdate,[year, month, day]);
      }
    });
  }

  /**
   * 时间选择
   */
  timePickerDialog(dateCallback: Function) {
    TimePickerDialog.show({
      selected:new Date(CommonConstants.SELECT_TIME),
      useMilitaryTime: true,
      onAccept: (value: TimePickerResult) => {
        let hour: number = Number(value.hour);
        let minute: number = Number(value.minute);
        let time: string =`${this.padZero(hour)}:${this.padZero(minute)}`
        dateCallback(time,[hour, minute]);
      }
    });
  }
  padZero(value:number):number|string {
    return value < 10 ? `0${value}` : value;
  }

  /**
   * 文本选择
   */
  textPickerDialog(sexArray?: string[], sexCallback?: Function) {
    if (this.isEmpty(sexArray)) {
      return;
    }
    TextPickerDialog.show({
      range: sexArray,
      selected: 0,
      onAccept: (result: TextPickerResult) => {
        sexCallback(result.value);
      },
      onCancel: () => {
      }
    });
  }


  /**
   * Check obj is empty
   *
   * @param {object} obj
   * @return {boolean} true(empty)
   */
  isEmpty(obj: object | string): boolean {
    return obj === undefined || obj === null || obj === '';
  }

}

export default new CommonUtils();

3.封装本地持久化数据preferences操作

在ets/model/database 新建文件 PreferencesHandler.ets

import data_preferences from '@ohos.data.preferences';
import CommonConstants from '../../common/constants/CommonConstants';
import PreferencesListener from './PreferencesListener';

/**
 * Based on lightweight databases preferences handler.
 */
export default class PreferencesHandler {
  static instance: PreferencesHandler = new PreferencesHandler();
  private preferences: data_preferences.Preferences | null = null;
  private defaultValue = '';
  private listeners: PreferencesListener[];

  private constructor() {
    this.listeners = new Array();
  }

  /**
   * Configure PreferencesHandler.
   *
   * @param context Context
   */
  public async configure(context: Context) {
    this.preferences = await data_preferences.getPreferences(context, CommonConstants.PREFERENCE_ID);
    this.preferences.on('change', (data: Record<string, Object>) => {
      for (let preferencesListener of this.listeners) {
        preferencesListener.onDataChanged(data.key as string);
      }
    });
  }

  /**
   * Set data in PreferencesHandler.
   *
   * @param key string
   * @param value any
   */
  public async set(key: string, value: string) {
    if (this.preferences != null) {
      await this.preferences.put(key, value);
      await this.preferences.flush();
    }
  }

  /**
   * 获取数据
   *
   * @param key string
   * @param defValue any
   * @return data about key
   */
  public async get(key: string) {
    let data: string = '';
    if (this.preferences != null) {
      data = await this.preferences.get(key, this.defaultValue) as string;
    }
    return data;
  }

  /**
   * 删除数据
   *
   * @param key string
   * @param defValue any
   * @return data about key
   */
  public async delete(key: string) {
    if (this.preferences != null) {
       await this.preferences.delete(key);
    }
  }

  /**
   * Clear data in PreferencesHandler.
   */
  public clear() {
    if (this.preferences != null) {
      this.preferences.clear();
    }
  }

  /**
   * Add preferences listener in PreferencesHandler.
   *
   * @param listener PreferencesListener
   */
  public addPreferencesListener(listener: PreferencesListener) {
    this.listeners.push(listener);
  }
}

4.封装代理提醒reminderAgentManager

在ets/model 目录下新建 ReminderService.ets

/*
 * Copyright (c) 2022 Huawei Device Co., Ltd.
 * Licensed under the Apache License,Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import reminderAgent from '@ohos.reminderAgentManager';
import notification from '@ohos.notificationManager';
import ReminderItem from '../viewmodel/ReminderItem';

/**
 * Base on ohos reminder agent service
 */
export default class ReminderService {
  /**
   * 打开弹窗
   */
  public openNotificationPermission() {
    notification.requestEnableNotification().then(() => {
    }).catch((err: Error) => {
    });
  }

  /**
   * 发布相应的提醒代理
   *
   * @param alarmItem ReminderItem
   * @param callback callback
   */
  public addReminder(alarmItem: ReminderItem, callback?: (reminderId: number) => void) {
    let reminder = this.initReminder(alarmItem);
    reminderAgent.publishReminder(reminder, (err, reminderId: number) => {
      if (callback != null) {
        callback(reminderId);
      }
    });
  }

  /**
   * 根据需要删除提醒任务。
   *
   * @param reminderId number
   */
  public deleteReminder(reminderId: number) {
    reminderAgent.cancelReminder(reminderId);
  }

  private initReminder(item: ReminderItem):  reminderAgent.ReminderRequestCalendar {
    return {
      reminderType: reminderAgent.ReminderType.REMINDER_TYPE_CALENDAR,
      title: item.title,
      content: item.content,
      dateTime: item.dateTime,
      repeatDays: item.repeatDays,
      ringDuration: item.ringDuration,
      snoozeTimes: item.snoozeTimes,
      timeInterval: item.timeInterval,
      actionButton: [
        {
          title: '关闭',
          type: reminderAgent.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE,
        },
        {
          title: '稍后提醒',
          type: reminderAgent.ActionButtonType.ACTION_BUTTON_TYPE_SNOOZE
        },
      ],
      wantAgent: {
        pkgName: 'com.example.wuyandeduihua2',// 点击提醒通知后跳转的目标UIAbility信息
        abilityName: 'EntryAbility'
      },
      maxScreenWantAgent: { // 全屏显示提醒到达时自动拉起的目标UIAbility信息
        pkgName: 'com.example.wuyandeduihua2',
        abilityName: 'EntryAbility'
      },
      notificationId: item.notificationId,
      expiredContent: '消息已过期',
      snoozeContent: '确定要延迟提醒嘛',
      slotType: notification.SlotType.SERVICE_INFORMATION
    }
  }
}

5.新增编辑提醒页面

新增编辑AddNeedPage.ets页面 代码如下

import router from '@ohos.router';

import  {FormList,FormItemType,formDataType} from  '../viewmodel/AddNeedModel'
import CommonUtils from '../common/utils/CommonUtils';
import addModel from '../viewmodel/AddNeedModel';

@Entry
@Component
struct AddNeedPage {
  @State formData:formDataType={
     id:0,
     title:"",
     content:"",
     remindDay:[], //日期
     remindDay_text:"",
     remindTime:[],//时间
     remindTime_text:"",
     ringDuration:0,
     ringDuration_text:"", //提醒时长
     snoozeTimes:0,
     snoozeTimes_text:"", //延迟提醒次数
     timeInterval:0,
     timeInterval_text:"", //延迟提醒间隔
  }
  private viewModel: addModel = addModel.instant;

  aboutToAppear() {
    let params = router.getParams() as Record<string, Object|undefined>;
    if (params !== undefined) {
      let alarmItem: formDataType = params.alarmItem as formDataType;
      if (alarmItem !== undefined) {
        this.formData =  {...alarmItem}
      }
    }
  }

  build() {
    Column() {
      Column(){
        ForEach(FormList,(item:FormItemType)=>{
          Row(){
            Row(){
              Text(item.title)
            }.width('35%')
            Row(){
              TextInput({ text: item.type.includes('Picker')?this.formData[`${item.key}_text`]: this.formData[item.key],placeholder: item.placeholder })
                .borderRadius(0)
                .enabled(item.isPicker?false:true) //禁用
                .backgroundColor('#ffffff')
                .onChange((value: string) => {
                  if(!item.type.includes('Picker')){
                    this.formData[item.key] = value;
                  }
                })
              Image($r('app.media.ic_arrow'))
                .visibility(item.isPicker?Visibility.Visible:Visibility.Hidden)
                .width($r('app.float.arrow_image_width'))
                .height($r('app.float.arrow_image_height'))
                .margin({ right: $r('app.float.arrow_right_distance') })

            }.width('65%').padding({right:15})
            .onClick(()=>{
              if(item.isPicker){
                switch (item.type) {
                  case 'datePicker':
                    CommonUtils.datePickerDialog((value: string,timeArray:string[]) => {
                      this.formData[`${item.key}_text`] = value;
                      this.formData[item.key] = timeArray;
                    });
                    break;
                  case 'timePicker':
                    CommonUtils.timePickerDialog((value: string,timeArray:string[]) => {
                      this.formData[`${item.key}_text`] = value;
                      this.formData[item.key] = timeArray;
                    });
                    break;
                  case 'TextPicker':
                    CommonUtils.textPickerDialog(item.dicData, (value: string) => {
                      this.formData[`${item.key}_text`] = value;
                      this.formData[`${item.key}`] =item.dicMap[value];
                    });
                    break;

                  default:
                    break;
                }
              }
            })
          }.width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .backgroundColor('#ffffff')
          .padding(10)
          .borderWidth({
            bottom:1
          })
          .borderColor('#f7f7f7')
        })

        Button('提交',{ type: ButtonType.Normal, stateEffect: true })
          .fontSize(18)
          .width('90%')
          .height(40)
          .borderRadius(15)
          .margin({ top:45 })
          .onClick(()=>{
            this.viewModel.setAlarmRemind(this.formData);
            router.back();
          })
      }
    }.width('100%')
    .height('100%')
    .backgroundColor('#f7f7f7')
  }
}

AddNeedModel.ets页面代码如下

import CommonConstants from '../common/constants/CommonConstants';
import ReminderService from '../model/ReminderService';
import DataTypeUtils from '../common/utils/DataTypeUtils';
import { GlobalContext } from '../common/utils/GlobalContext';
import PreferencesHandler from '../model/database/PreferencesHandler';

/**
 * Detail page view model description
 */
export default class DetailViewModel {
  static instant: DetailViewModel = new DetailViewModel();
  private reminderService: ReminderService;
  private alarms: Array<formDataType>;

  private constructor() {
    this.reminderService = new ReminderService();
    this.alarms = new Array<formDataType>();
  }

  /**
   * 设置提醒
   *
   * @param alarmItem AlarmItem
   */
  public async setAlarmRemind(alarmItem: formDataType) {

    let index = await this.findAlarmWithId(alarmItem.id);
    if (index !== CommonConstants.DEFAULT_NUMBER_NEGATIVE) {
      this.reminderService.deleteReminder(alarmItem.id);
    } else {
      index = this.alarms.length;
      alarmItem.notificationId = index;
      this.alarms.push(alarmItem);
    }
    alarmItem.dateTime={
      year: alarmItem.remindDay[0],
      month: alarmItem.remindDay[1],
      day: alarmItem.remindDay[2],
      hour: alarmItem.remindTime[0],
      minute: alarmItem.remindTime[1],
      second: 0
    }
    // @ts-ignore
    this.reminderService.addReminder(alarmItem, (newId: number) => {
      alarmItem.id = newId;
      this.alarms[index] = alarmItem;
      let preference = GlobalContext.getContext().getObject('preference') as PreferencesHandler;
      preference.set(CommonConstants.ALARM_KEY, JSON.stringify(this.alarms));

    })
  }

  /**
   * 删除提醒
   *
   * @param id number
   */
  public async removeAlarmRemind(id: number) {
    this.reminderService.deleteReminder(id);
    let index = await this.findAlarmWithId(id);
    if (index !== CommonConstants.DEFAULT_NUMBER_NEGATIVE) {
      this.alarms.splice(index, CommonConstants.DEFAULT_SINGLE);
    }
    let preference = GlobalContext.getContext().getObject('preference') as PreferencesHandler;
    preference.set(CommonConstants.ALARM_KEY, JSON.stringify(this.alarms));
  }

  private async findAlarmWithId(id: number) {
    let preference = GlobalContext.getContext().getObject('preference') as PreferencesHandler;
    let data = await preference.get(CommonConstants.ALARM_KEY);
    if (!DataTypeUtils.isNull(data)) {
      this.alarms = JSON.parse(data);
      for (let i = 0;i < this.alarms.length; i++) {
        if (this.alarms[i].id === id) {
          return i;
        }
      }
    }
    return CommonConstants.DEFAULT_NUMBER_NEGATIVE;
  }
}


export  interface  FormItemType{
  title:string;
  placeholder:string;
  type:string;
  key:string;
  isPicker:boolean;
  dicData?:string[]
  dicMap?:object
}


export  interface  formDataType{
  id:number;
  notificationId?:number;
  title:string;
  content:string;
  remindDay:number[];
  remindDay_text:string;
  remindTime:number[];
  remindTime_text:string;
  ringDuration:number;
  ringDuration_text:string;
  snoozeTimes:number;
  snoozeTimes_text:string;
  timeInterval:number;
  timeInterval_text:string;
  dateTime?:Object

}


export const FormList: Array<FormItemType> = [
  {
    title:"事项名称",
    placeholder:"请输入",
    key:"title",
    isPicker:false,
    type:"text"
  },
  {
    title:"事项描述",
    placeholder:"请输入",
    key:"content",
    isPicker:false,
    type:"text"
  },
  {
    title:"提醒日期",
    placeholder:"请选择",
    key:"remindDay",
    isPicker:true,
    type:"datePicker"
  },
  {
    title:"提醒时间",
    placeholder:"请选择",
    key:"remindTime",
    isPicker:true,
    type:"timePicker"
  },
  {
    title:"提醒时长",
    placeholder:"请选择",
    key:"ringDuration",
    isPicker:true,
    type:"TextPicker",
    dicData:['30秒','1分钟','5分钟'],
    dicMap:{
      '30秒':30,
      '1分钟':60,
      '5分钟':60*5,
    }
  },
  {
    title:"延迟提醒次数",
    placeholder:"请选择",
    key:"snoozeTimes",
    isPicker:true,
    type:"TextPicker",
    dicData:['1次','2次','3次','4次','5次','6次'],
    dicMap:{
      '1次':1,
      '2次':2,
      '3次':3,
      '4次':4,
      '5次':5,
      '6次':6,
    }
  },
  {
    title:"延迟提醒间隔",
    placeholder:"请选择",
    key:"timeInterval",
    isPicker:true,
    type:"TextPicker",
    dicData:['5分钟','10分钟','15分钟','30分钟'],
    dicMap:{
      '5分钟':5*60,
      '10分钟':10*60,
      '15分钟':15*60,
      '30分钟':15*60,
    }
  }
]

注意事项

  • 本项目用到了代理通知需要在module.json5 文件中 requestPermissions 中声明权限

    {
    "module": {
    "name": "entry",
    "type": "entry",
    "description": "string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ "phone", "tablet" ], "deliveryWithInstall": true, "installationFree": false, "pages": "profile:main_pages",
    "abilities": [
    {
    "name": "EntryAbility",
    "srcEntry": "./ets/entryability/EntryAbility.ets",
    "description": "string:ability_desc", "icon": "media:icon",
    "label": "string:ability_label", "startWindowIcon": "media:icon",
    "startWindowBackground": "color:start_window_background", "exported": true, "skills": [ { "entities": [ "entity.system.home" ], "actions": [ "action.system.home" ] } ] } ], "requestPermissions": [ { "name": "ohos.permission.PUBLISH_AGENT_REMINDER", "reason": "string:reason",
    "usedScene": {
    "abilities": [
    "EntryAbility"
    ],
    "when": "always"
    }
    }
    ]
    }
    }

  • 本项目还用到了 应用上下文Context在入口文件EntryAbility.ets中注册

    import type AbilityConstant from '@ohos.app.ability.AbilityConstant';
    import display from '@ohos.display';
    import hilog from '@ohos.hilog';
    import UIAbility from '@ohos.app.ability.UIAbility';
    import type Want from '@ohos.app.ability.Want';
    import type window from '@ohos.window';
    import PreferencesHandler from '../model/database/PreferencesHandler';
    import { GlobalContext } from '../common/utils/GlobalContext';
    /**

    • Lift cycle management of Ability.
      */
      export default class EntryAbility extends UIAbility {
      onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
      hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
      GlobalContext.getContext().setObject('preference', PreferencesHandler.instance);
      }
    onDestroy(): void {
      hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy');
    }
    
    async onWindowStageCreate(windowStage: window.WindowStage) {
      // Main window is created, set main page for this ability
      let globalDisplay: display.Display = display.getDefaultDisplaySync();
      GlobalContext.getContext().setObject('globalDisplay', globalDisplay);
      let preference = GlobalContext.getContext().getObject('preference') as PreferencesHandler;
      await preference.configure(this.context.getApplicationContext());
    
      // Main window is created, set main page for this ability
      hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
    
      windowStage.loadContent("pages/MinePage", (err, data) => {
        if (err.code) {
          hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
          return;
        }
        hilog.info(0x0000, 'testTag', 'Succeeded in loading the content. Data: %{public}s', JSON.stringify(data) ?? '');
      });
    }
    
    onWindowStageDestroy(): void {
      // Main window is destroyed, release UI related resources
      hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
    }
    
    onForeground(): void {
      // Ability has brought to foreground
      hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground');
    }
    
    onBackground(): void {
      // Ability has back to background
      hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground');
    }
    

    }

以上,总结了应用程序的主要代码内容。相关代码我把他放在了github上有需要的小伙伴自己下载

最后

小编在之前的鸿蒙系统扫盲中,有很多朋友给我留言,不同的角度的问了一些问题,我明显感觉到一点,那就是许多人参与鸿蒙开发,但是又不知道从哪里下手,因为资料太多,太杂,教授的人也多,无从选择。有很多小伙伴不知道学习哪些鸿蒙开发技术?不知道需要重点掌握哪些鸿蒙应用开发知识点?而且学习时频繁踩坑,最终浪费大量时间。所以有一份实用的鸿蒙(HarmonyOS NEXT)文档用来跟着学习是非常有必要的。

为了确保高效学习,建议规划清晰的学习路线,涵盖以下关键阶段:


鸿蒙(HarmonyOS NEXT)最新学习路线

该路线图包含基础技能、就业必备技能、多媒体技术、六大电商APP、进阶高级技能、实战就业级设备开发,不仅补充了华为官网未涉及的解决方案

路线图适合人群:

IT开发人员: 想要拓展职业边界
零基础小白: 鸿蒙爱好者,希望从0到1学习,增加一项技能。
**技术提升/进阶跳槽:**发展瓶颈期,提升职场竞争力,快速掌握鸿蒙技术

2.视频学习教程+学习PDF文档

HarmonyOS Next 最新全套视频教程

纯血版鸿蒙全套学习文档(面试、文档、全套视频等)

​​

总结

参与鸿蒙开发,你要先认清适合你的方向,如果是想从事鸿蒙应用开发方向的话,可以参考本文的学习路径,简单来说就是:为了确保高效学习,建议规划清晰的学习路线

相关推荐
yuwinter2 分钟前
鸿蒙HarmonyOS学习笔记(2)
笔记·学习·harmonyos
2202_754421548 分钟前
生成MPSOC以及ZYNQ的启动文件BOOT.BIN的小软件
java·linux·开发语言
醉の虾21 分钟前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
ZZZCY200321 分钟前
华为ENSP--IP编址及静态路由配置
网络·华为
张小小大智慧29 分钟前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm39 分钟前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
运维&陈同学1 小时前
【zookeeper03】消息队列与微服务之zookeeper集群部署
linux·微服务·zookeeper·云原生·消息队列·云计算·java-zookeeper
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue