Create menus

 

Menus are an important part of any application. They provide familiar interfaces that reveal application functions and settings. Android offers an easy programming interface for developers to provide standardized application menus for various situations.

Android offers three fundamental types of application menus:

Options Menu
This is the primary set of menu items for an Activity. It is revealed by pressing the device MENU key. Within the Options Menu are two groups of menu items:
Icon Menu
This is the collection of items initially visible at the bottom of the screen at the press of the MENU key. It supports a maximum of six menu items. These are the only menu items that support icons and the only menu items that do not support checkboxes or radio buttons.
Expanded Menu
This is a vertical list of items exposed by the "More" menu item from the Icon Menu. It exists only when the Icon Menu becomes over-loaded and is comprised of the sixth Option Menu item and the rest.
Context Menu
This is a floating list of menu items that may appear when you perform a long-press on a View (such as a list item).
Submenu
This is a floating list of menu items that is revealed by an item in the Options Menu or a Context Menu. A Submenu item cannot support nested Submenus.

Options Menu

The Options Menu is opened by pressing the device MENU key. When opened, the Icon Menu is displayed, which holds the first six menu items. If more than six items are added to the Options Menu, then those that can't fit in the Icon Menu are revealed in the Expanded Menu, via the "More" menu item. The Expanded Menu is automatically added when there are more than six items.

The Options Menu is where you should include basic application functions and any necessary navigation items (e.g., to a home screen or application settings). You can also add Submenus for organizing topics and including extra menu functionality.

When this menu is opened for the first time, the Android system will call the Activity onCreateOptionsMenu() callback method. Override this method in your Activity and populate the Menu object given to you. You can populate the menu by inflating a menu resource that was defined in XML, or by calling add() for each item you'd like in the menu. This method adds a MenuItem, and returns the newly created object to you. You can use the returned MenuItem to set additional properties like an icon, a keyboard shortcut, an intent, and other settings for the item.

There are multiple add() methods. Usually, you'll want to use one that accepts an itemId argument. This is a unique integer that allows you to identify the item during a callback.

When a menu item is selected from the Options Menu, you will recieve a callback to the onOptionsItemSelected() method of your Activity. This callback passes you the MenuItem that has been selected. You can identify the item by requesting the itemId, with getItemId(), which returns the integer that was assigned with the add() method. Once you identify the menu item, you can take the appropriate action.

Here's an example of this procedure, inside an Activity, wherein we create an Options Menu and handle item selections:

/* Creates the menu items */
public boolean onCreateOptionsMenu(Menu menu) {
    menu.add(0, MENU_NEW_GAME, 0, "New Game");
    menu.add(0, MENU_QUIT, 0, "Quit");
    return true;
}

/* Handles item selections */
public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
    case MENU_NEW_GAME:
        newGame();
        return true;
    case MENU_QUIT:
        quit();
        return true;
    }
    return false;
}

The add() method used in this sample takes four arguments: groupId, itemId, order, and title. The groupId allows you to associate this menu item with a group of other items (more about Menu groups, below) — in this example, we ignore it. itemId is a unique integer that we give the MenuItem so that can identify it in the next callback. order allows us to define the display order of the item — by default, they are displayed by the order in which we add them. title is, of course, the name that goes on the menu item (this can also be a string resource, and we recommend you do it that way for easier localization).

Tip: If you have several menu items that can be grouped together with a title, consider organizing them into a Submenu.

Adding icons

Icons can also be added to items that appears in the Icon Menu with setIcon(). For example:

menu.add(0, MENU_QUIT, 0, "Quit")
    .setIcon(R.drawable.menu_quit_icon);

Modifying the menu

If you want to sometimes re-write the Options Menu as it is opened, override the onPrepareOptionsMenu() method, which is called each time the menu is opened. This will pass you the Menu object, just like the onCreateOptionsMenu() callback. This is useful if you'd like to add or remove menu options depending on the current state of an application or game.

Note: When changing items in the menu, it's bad practice to do so based on the currently selected item. Keep in mind that, when in touch mode, there will not be a selected (or focused) item. Instead, you should use a Context Menu for such behaviors, when you want to provide functionality based on a particular item in the UI.

Context Menu

The Android context menu is similar, in concept, to the menu revealed with a "right-click" on a PC. When a view is registered to a context menu, performing a "long-press" (press and hold for about two seconds) on the object will reveal a floating menu that provides functions relating to that item. Context menus can be registered to any View object, however, they are most often used for items in a ListView, which helpfully indicates the presence of the context menu by transforming the background color of the ListView item when pressed. (The items in the phone's contact list offer an example of this feature.)

Note: Context menu items do not support icons or shortcut keys.

To create a context menu, you must override the Activity's context menu callback methods: onCreateContextMenu() and onContextItemSelected(). Inside the onCreateContextMenu() callback method, you can add menu items using one of the add() methods, or by inflating a menu resource that was defined in XML. Then, register a ContextMenu for the View, with registerForContextMenu().

For example, here is some code that can be used with the Notepad application to add a context menu for each note in the list:

public void onCreateContextMenu(ContextMenu menu, View v,
                                ContextMenuInfo menuInfo) {
  super.onCreateContextMenu(menu, v, menuInfo);
  menu.add(0, EDIT_ID, 0, "Edit");
  menu.add(0, DELETE_ID, 0,  "Delete");
}

public boolean onContextItemSelected(MenuItem item) {
  AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
  switch (item.getItemId()) {
  case EDIT_ID:
    editNote(info.id);
    return true;
  case DELETE_ID:
    deleteNote(info.id);
    return true;
  default:
    return super.onContextItemSelected(item);
  }
}

In onCreateContextMenu(), we are given not only the ContextMenu to which we will add MenuItems, but also the View that was selected and a ContextMenuInfo object, which provides additional information about the object that was selected. In this example, nothing special is done in onCreateContextMenu() — just a couple items are added as usual. In the onContextItemSelected() callback, we request the AdapterContextMenuInfo from the MenuItem, which provides information about the currently selected item. All we need from this is the list ID for the selected item, so whether editing a note or deleting it, we find the ID with the AdapterContextMenuInfo.info field of the object. This ID is passed to the editNote() and deleteNote() methods to perfrom the respective action.

Now, to register this context menu for all the items in a ListView, we pass the entire ListView to the registerForContextMenu(View) method:

registerForContextMenu(getListView());

Remember, you can pass any View object to register a context menu. Here, getListView() returns the ListView object used in the Notepad application's ListActivity. As such, each item in the list is registered to this context menu.

A sub menu can be added within any menu, except another sub menu. These are very useful when your application has a lot of functions that may be organized in topics, like the items in a PC application's menu bar (File, Edit, View, etc.).

A sub menu is created by adding it to an existing Menu with addSubMenu(). This returns a SubMenu object (an extension of Menu). You can then add additional items to this menu, with the normal routine, using the add() methods. For example:

public boolean onCreateOptionsMenu(Menu menu) {
  boolean result = super.onCreateOptionsMenu(menu);

  SubMenu fileMenu = menu.addSubMenu("File");
  SubMenu editMenu = menu.addSubMenu("Edit");
  fileMenu.add("new");
  fileMenu.add("open");
  fileMenu.add("save");
  editMenu.add("undo");
  editMenu.add("redo");

  return result;
}

Callbacks for items selected in a sub menu are made to the parent menu's callback method. For the example above, selections in the sub menu will be handled by the onOptionsItemSelected() callback.

You can also add Submenus when you define the parent menu in XML.

Define Menus in XML

Just like Android UI layouts, you can define application menus in XML, then inflate them in your menu's onCreate...() callback method. This makes your application code cleaner and separates more interface design into XML, which is easier to visualize.

To start, create a new folder in your project res/ directory called menu. This is where you should keep all XML files that define your application menus.

In a menu XML layout, there are three valid elements: <menu>, <group> and <item>. The item and group elements must be children of a menu, but item elements may also be the children of a group, and another menu element may be the child of an item (to create a Submenu). Of course, the root node of any file must be a menu element.

As an example, we'll define the same menu created in the Options Menu section, above. We start with an XML file named options_menu.xml inside the res/menu/ folder:

<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@+id/new_game"
          android:title="New Game" />
    <item android:id="@+id/quit"
          android:title="Quit" />
</menu>

Then, in the onCreateOptionsMenu() method, we inflate this resource using MenuInflater.inflate():

public boolean onCreateOptionsMenu(Menu menu) {
    MenuInflater inflater = getMenuInflater();
    inflater.inflate(R.menu.options_menu, menu);
    return true;
}

The getMenuInflater() method returns the MenuInflater for our activity's context. We then call inflate(), passing it a pointer to our menu resource and the Menu object given by the callback.

While this small sample may seem like more effort, compared to creating the menu items in the onCreateOptionsMenu() method, this will save a lot of trouble when dealing with more items and it keeps your application code clean.

You can define menu groups by wrapping item elements in a group element, and create Submenus by nesting another menu inside an item. Each element also supports all the necessary attributes to control features like shortcut keys, checkboxes, icons, and more. To learn about these attributes and more about the XML syntax, see the Menus topic in the Available Resource Types document.

Menu Features

Here are some other features that can be applied to most menu items.

Menu groups

When adding new items to a menu, you can optionally include each item in a group. A menu group is a collection of menu items that can share certain traits, like whether they are visible, enabled, or checkable.

A group is defined by an integer (or a resource id, in XML). A menu item is added to the group when it is added to the menu, using one of the add() methods that accepts a groupId as an argument, such as add(int, int, int, int).

You can show or hide the entire group with setGroupVisible(); enable or disable the group with setGroupEnabled(); and set whether the items can be checkable with setGroupCheckable().

Checkable menu items

Any menu item can be used as an interface for turning options on and off. This can be indicated with a checkbox for stand-alone options, or radio buttons for groups of mutually exlusive options (see the screenshot, to the right).

Note: Menu items in the Icon Menu cannot display a checkbox or radio button. If you choose to make items in the Icon Menu checkable, then you must personally indicate the state by swapping the icon and/or text each time the state changes between on and off.

To make a single item checkable, use the setCheckable() method, like so:

menu.add(0, VIBRATE_SETTING_ID, 0, "Vibrate")
    .setCheckable(true);

This will display a checkbox with the menu item (unless it's in the Icon Menu). When the item is selected, the onOptionsItemSelected() callback is called as usual. It is here that you must set the state of the checkbox. You can query the current state of the item with isChecked() and set the checked state with setChecked(). Here's what this looks like inside the onOptionsItemSelected() callback:

switch (item.getItemId()) {
case VIBRATE_SETTING_ID:
  if (item.isChecked()) item.setChecked(false);
  else item.setChecked(true);
  return true;
...
}

To make a group of mutually exclusive radio button items, simply assign the same group ID to each menu item and call setGroupCheckable(). In this case, you don't need to call setCheckable() on each menu items, because the group as a whole is set checkable. Here's an example of two mutually exclusive options in a Submenu:

SubMenu subMenu = menu.addSubMenu("Color");
subMenu.add(COLOR_MENU_GROUP, COLOR_RED_ID, 0, "Red");
subMenu.add(COLOR_MENU_GROUP, COLOR_BLUE_ID, 0, "Blue");
subMenu.setGroupCheckable(COLOR_MENU_GROUP, true, true);

In the setGroupCheckable() method, the first argument is the group ID that we want to set checkable. The second argument is whether we want the group items to be checkable. The last one is whether we want each item to be exclusively checkable (if we set this false, then all the items will be checkboxes instead of radio buttons). When the group is set to be exclusive (radio buttons), each time a new item is selected, all other are automatically de-selected.

 

Note: Checkable menu items are intended to be used only on a per-session basis and not saved to the device (e.g., the Map mode setting in the Maps application is not saved — screenshot above). If there are application settings that you would like to save for the user, then you should store the data using Preferences, and manage them with a PreferenceActivity.

Shortcut keys

Quick access shortcut keys using letters and/or numbers can be added to menu items with setAlphabeticShortcut(char) (to set char shortcut), setNumericShortcut(int) (to set numeric shortcut), or setShortcut(char,int) (to set both). Case is not sensitive. For example:

menu.add(0, MENU_QUIT, 0, "Quit")
    .setAlphabeticShortcut('q');

Now, when the menu is open (or while holding the MENU key), pressing the "q" key will select this item.

This shortcut key will be displayed as a tip in the menu item, below the menu item name (except for items in the Icon Menu).

Note: Shortcuts cannot be added to items in a Context Menu.

Menu item intents

If you've read the Application Fundamentals, then you're at least a little familiar with Android Intents. These allow applications to bind with each other, share information, and perform user tasks cooperatively. Just like your application might fire an Intent to launch a web browser, an email client, or another Activity in your application, you can perform such actions from within a menu. There are two ways to do this: define an Intent and assign it to a single menu item, or define an Intent and allow Android to search the device for activities and dynamically add a menu item for each one that meets the Intent criteria.

For more information on creating Intents and providing your application's services to other applications, read the Intents and Intent Filters document.

Set an intent for a single menu item

If you want to offer a specific menu item that launches a new Activity, then you can specifically define an Intent for the menu item with the setIntent() method.

For example, inside the onCreateOptionsMenu() method, you can define a new menu item with an Intent like this:

MenuItem menuItem = menu.add(0, PHOTO_PICKER_ID, 0, "Select Photo");
menuItem.setIntent(new Intent(this, PhotoPicker.class));

Android will automatically launch the Activity when the item is selected.

Note: This will not return a result to your Activity. If you wish to be returned a result, then do not use setIntent(). Instead, handle the selection as usual in the onOptionsMenuItemSelected() or onContextMenuItemSelected() callback and call startActivityForResult().

Dynamically add intents

If there are potentially multiple activities that are relevant to your current Activity or selected item, then the application can dynamically add menu items that execute other services.

During menu creation, define an Intent with the category Intent.ALTERNATIVE_CATEGORY and/or Intent.SELECTED_ALTERNATIVE, the MIME type currently selected (if any), and any other requirements, the same way as you would satisfy an intent filter to open a new Activity. Then call addIntentOptions() to have Android search for any services meeting those requirements and add them to the menu for you. If there are no applications installed that satisfy the Intent, then no additional menu items are added.

Note: SELECTED_ALTERNATIVE is used to handle the currently selected element on the screen. So, it should only be used when creating a Menu in onCreateContextMenu() or onPrepareOptionsMenu(), which is called every time the Options Menu is opened.

Here's an example demonstrating how an application would search for additional services to display on its menu.

public boolean onCreateOptionsMenu(Menu menu){
    super.onCreateOptionsMenu(menu);

    // Create an Intent that describes the requirements to fulfill, to be included
    // in our menu. The offering app must include a category value of Intent.CATEGORY_ALTERNATIVE. 
    Intent intent = new Intent(null, getIntent().getData());
    intent.addCategory(Intent.CATEGORY_ALTERNATIVE);
        
    // Search for, and populate the menu with, acceptable offering applications.
    menu.addIntentOptions(
         thisClass.INTENT_OPTIONS,  // Menu group 
         0,      // Unique item ID (none)
         0,      // Order for the items (none)
         this.getComponentName(),   // The current Activity name
         null,   // Specific items to place first (none)
         intent, // Intent created above that describes our requirements
         0,      // Additional flags to control items (none)
         null);  // Array of MenuItems that corrolate to specific items (none)

    return true;
}

For each Activity found that provides an Intent Filter matching the Intent defined, a menu item will be added, using the android:label value of the intent filter as the text for the menu item. The addIntentOptions() method will also return the number of menu items added.

Also be aware that, when addIntentOptions() is called, it will override any and all menu items in the menu group specified in the first argument.

If you wish to offer the services of your Activity to other application menus, then you only need to define an intent filter as usual. Just be sure to include the ALTERNATIVE and/or SELECTED_ALTERNATIVE values in the name attribute of a <category> element in the intent filter. For example:

<intent-filter label="Resize Image">
    ...
    <category android:name="android.intent.category.ALTERNATIVE" />
    <category android:name="android.intent.category.SELECTED_ALTERNATIVE" />
    ...
</intent-filter>

read more about writing intent filters in the Intents and Intent Filters document.

For a sample application using this technique, see the Note Pad sample code.

classdef ECG_Monitor_System < handle properties % 新增数据通信窗口属性 dataTransferFig % 数据传输子窗口 transferPortList % 可用串口列表 transferSerialPort % 传输用串口对象 fileData % 从文件加载的数据 transferTimer % 数据传输定时器 isTransferring = false % 传输状态标志 % 新增子窗口控件 filePathText portDropdown transferButton statusTextTransfer progressBar % 通信相关 serialPort % 串口对象 isConnected = false comPort = 'COM1' % 默认串口 baudRate = 115200 % OEM协议常用波特率 dataBits = 8 % 数据位 parity = 'none' % 校验位 stopBits = 1 % 停止位 % 数据管理 ecgData = zeros(12, 10000); filteredData = zeros(12, 10000); timeStamps = zeros(1, 10000); sampleRate = 1000; currentIndex = 1; dataLength = 0; isRecording = false; timerObj maxDataPoints = 10000 % 最大数据点数 numLeads = 12 % 导联数量 activeLead = 2 % 默认显示II导联 % 滤波参数 highPassFreq = 0.5; lowPassFreq = 100; notchFreq = 50; % 工频陷波 % 分析参数 heartRate = 0; rPeakPositions = []; qtInterval = 0; stSegment = 0; % 心电特征参数 qtIntervals = [] % QT间期数组(秒) stSegments = [] % ST段偏移数组(mV) avgQT = 0 % 平均QT间期 avgST = 0 % 平均ST偏移 % GUI组件 fig % 主控制窗口 overviewFig % 总览窗口 singleLeadFigs = gobjects(12, 1); % 单导联窗口 leadPlots = gobjects(12, 1); % 单导联绘图句柄 overviewPlots = gobjects(12, 1); % 总览窗口绘图句柄 leadNames = {'I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'}; % 控制面板组件 controlPanel highPassEdit lowPassEdit notchCheckbox statusText heartRateText recordButton saveButton leadSelect dataSlider positionText % 频谱分析 spectrumAx % 报告生成 patientInfo = struct('name', '未知', 'gender', '未知', 'age', 0, 'id', ''); end methods function app = ECG_Monitor_System() app.createMainGUI(); app.createOverviewWindow(); app.initializeSystem(); end function createMainGUI(app) % 创建主控制窗口 app.fig = figure('Name', '心电数据管理系统', 'NumberTitle', 'off',... 'Position', [100, 100, 800, 600], 'CloseRequestFcn', @app.onClose,... 'MenuBar', 'none', 'ToolBar', 'none'); % 创建菜单栏 app.createMenus(); % 创建控制面板 app.createControlPanel(); end function createOverviewWindow(app) % 创建总览窗口 app.overviewFig = figure('Name', '心电图总览', 'NumberTitle', 'off',... 'Position', [950, 100, 1200, 800], 'CloseRequestFcn', @app.onOverviewClose); % 创建12导联网格布局 for i = 1:12 row = ceil(i/3); col = mod(i-1,3)+1; ax = subplot(4, 3, i, 'Parent', app.overviewFig); app.overviewPlots(i) = plot(ax, nan, nan, 'b-'); title(ax, app.leadNames{i}); xlabel(ax, '时间(s)'); ylabel(ax, '幅度(mV)'); grid(ax, 'on'); end end function createDataTransferWindow(app) % 创建数据传输子窗口 app.dataTransferFig = figure('Name', '数据通信', 'NumberTitle', 'off',... 'Position', [300, 300, 600, 400], 'MenuBar', 'none',... 'CloseRequestFcn', @app.onTransferClose); % 文件选择区域 uicontrol('Parent', app.dataTransferFig, 'Style', 'pushbutton',... 'String', '打开文件', 'Position', [20, 350, 80, 30],... 'Callback', @app.openFileForTransfer); app.filePathText = uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '未选择文件', 'Position', [110, 350, 350, 30],... 'HorizontalAlignment', 'left', 'FontSize', 10); % 串口选择区域 uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '选择串口:', 'Position', [20, 300, 80, 25], 'FontSize', 10); app.portDropdown = uicontrol('Parent', app.dataTransferFig, 'Style', 'popup',... 'String', {'无可用串口'}, 'Position', [110, 300, 150, 25],... 'FontSize', 10); % 扫描可用串口 app.scanAvailablePorts(); % 传输控制按钮 app.transferButton = uicontrol('Parent', app.dataTransferFig, 'Style', 'pushbutton',... 'String', '开始传输', 'Position', [280, 300, 100, 30],... 'Callback', @app.toggleTransfer, 'Enable', 'off'); % 状态显示 app.statusTextTransfer = uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '状态: 准备就绪', 'Position', [20, 250, 350, 25],... 'HorizontalAlignment', 'left', 'FontSize', 10); % 进度条 uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '传输进度:', 'Position', [20, 200, 80, 25], 'FontSize', 10); app.progressBar = uicontrol('Parent', app.dataTransferFig, 'Style', 'slider',... 'Position', [110, 200, 350, 25], 'Enable', 'off', 'Min', 0, 'Max', 1, 'Value', 0); % 绘图区域 - 显示12导联实时数据 plotPanel = uipanel('Parent', app.dataTransferFig, 'Title', '实时数据',... 'Position', [0.05, 0.05, 0.9, 0.4]); % 创建12导联网格布局 for i = 1:12 row = ceil(i/4); col = mod(i-1,4)+1; ax = subplot(3, 4, i, 'Parent', plotPanel); app.transferPlots(i) = plot(ax, nan, nan, 'b-'); title(ax, app.leadNames{i}); xlabel(ax, '时间(s)'); ylabel(ax, '幅度(mV)'); grid(ax, 'on'); end end function scanAvailablePorts(app) % 扫描系统可用串口 ports = serialportlist("available"); if isempty(ports) set(app.portDropdown, 'String', {'无可用串口'}); set(app.transferButton, 'Enable', 'off'); else set(app.portDropdown, 'String', ports); set(app.transferButton, 'Enable', 'on'); end app.transferPortList = ports; end %% function scanAvailablePorts2(app) % 扫描系统可用串口 ports = serialportlist("available"); if isempty(ports) set(app.portDropdown, 'String', {'无可用串口'}); set(app.transferButton, 'Enable', 'off'); else set(app.portDropdown, 'String', ports); set(app.transferButton, 'Enable', 'on'); end end function openFileForTransfer(app, ~, ~) % 打开心电数据文件 [filename, pathname] = uigetfile(... {'*.mat;*.csv;*.txt;*.bin', '心电数据文件 (*.mat, *.csv, *.txt, *.bin)'},... '选择心电数据文件'); if isequal(filename, 0) return; end filepath = fullfile(pathname, filename); set(app.filePathText, 'String', filepath); try [~, ~, ext] = fileparts(filepath); switch lower(ext) case '.mat' data = load(filepath); if isfield(data, 'ecgData') app.fileData.ecgData = data.ecgData; app.fileData.timeStamps = data.timeStamps; app.fileData.sampleRate = data.sampleRate; else error('MAT文件格式不支持'); end case {'.csv', '.txt'} data = readmatrix(filepath); if size(data, 2) >= 13 app.fileData.timeStamps = data(:, 1)'; app.fileData.ecgData = data(:, 2:13)'; app.fileData.sampleRate = 1 / mean(diff(app.fileData.timeStamps)); else error('CSV/TXT文件格式错误'); end case '.bin' % 二进制文件解析 (示例实现) fid = fopen(filepath, 'r'); header = fread(fid, 4, 'uint8'); app.fileData.sampleRate = fread(fid, 1, 'single'); numPoints = fread(fid, 1, 'uint32'); numLeads = fread(fid, 1, 'uint8'); data = fread(fid, [numLeads+1, numPoints], 'single'); fclose(fid); app.fileData.timeStamps = data(1, :); app.fileData.ecgData = data(2:end, :); end app.fileData.currentIndex = 1; app.fileData.totalPoints = size(app.fileData.ecgData, 2); set(app.statusTextTransfer, 'String',... sprintf('状态: 已加载 %s (%d 个样本)', filename, app.fileData.totalPoints)); set(app.progressBar, 'Min', 0, 'Max', app.fileData.totalPoints, 'Value', 0); catch ME errordlg(sprintf('文件读取错误: %s', ME.message), '文件错误'); end end function toggleTransfer(app, ~, ~) if app.isTransferring % 停止传输 app.stopTransfer(); set(app.transferButton, 'String', '开始传输'); set(app.statusTextTransfer, 'String', '状态: 传输已停止'); else % 开始传输 if isempty(app.fileData) || isempty(app.fileData.ecgData) errordlg('请先选择数据文件', '传输错误'); return; end ports = get(app.portDropdown, 'String'); selectedIdx = get(app.portDropdown, 'Value'); selectedPort = ports{selectedIdx}; app.startTransfer(selectedPort); set(app.transferButton, 'String', '停止传输'); set(app.statusTextTransfer, 'String', '状态: 传输中...'); end end function startTransfer(app, port) % 创建传输串口 try app.transferSerialPort = serialport(port, app.baudRate,... 'DataBits', app.dataBits,... 'Parity', app.parity,... 'StopBits', app.stopBits); % 创建传输定时器 interval = 1000 / app.fileData.sampleRate; % 按采样率间隔发送 app.transferTimer = timer(... 'ExecutionMode', 'fixedRate',... 'Period', interval,... 'TimerFcn', @(~,~) app.sendDataPacket(),... 'StopFcn', @(~,~) app.cleanupAfterTransfer()); app.isTransferring = true; start(app.transferTimer); catch ME errordlg(sprintf('串口初始化失败: %s', ME.message), '通信错误'); app.isTransferring = false; end end function sendDataPacket(app) % 发送单个数据包 if app.fileData.currentIndex > app.fileData.totalPoints app.stopTransfer(); return; end % 获取当前数据点 idx = app.fileData.currentIndex; timestamp = app.fileData.timeStamps(idx); ecgValues = app.fileData.ecgData(:, idx); % 生成OEM协议数据包 packet = zeros(1, 13, 'single'); packet(1) = timestamp; packet(2:13) = ecgValues; % 转换为字节流 dataBytes = typecast(packet, 'uint8'); % 添加帧头帧尾 fullPacket = [uint8([AA, 55]), dataBytes, uint8([55, AA])]; % 通过串口发送 write(app.transferSerialPort, fullPacket, 'uint8'); % 更新子窗口绘图 app.updateTransferPlots(idx); % 更新进度 app.fileData.currentIndex = idx + 1; set(app.progressBar, 'Value', idx); end function updateTransferPlots(app, idx) % 更新子窗口的12导联绘图 windowSize = 200; % 显示窗口大小 startIdx = max(1, idx - windowSize); endIdx = min(app.fileData.totalPoints, idx + windowSize); timeWindow = app.fileData.timeStamps(startIdx:endIdx); for i = 1:12 if ishandle(app.transferPlots(i)) set(app.transferPlots(i), 'XData', timeWindow,... 'YData', app.fileData.ecgData(i, startIdx:endIdx)); % 自动调整Y轴范围 data = app.fileData.ecgData(i, startIdx:endIdx); dataRange = range(data); if dataRange > 0 ax = get(app.transferPlots(i), 'Parent'); ylim(ax, [min(data)-0.1*dataRange, max(data)+0.1*dataRange]); end end end drawnow; end function stopTransfer(app) % 停止数据传输 if ~isempty(app.transferTimer) && isvalid(app.transferTimer) stop(app.transferTimer); delete(app.transferTimer); app.transferTimer = []; end if ~isempty(app.transferSerialPort) && isvalid(app.transferSerialPort) delete(app.transferSerialPort); app.transferSerialPort = []; end app.isTransferring = false; end function cleanupAfterTransfer(app) % 传输完成后的清理 set(app.transferButton, 'String', '开始传输'); set(app.statusTextTransfer, 'String', '状态: 传输完成'); app.isTransferring = false; end function onTransferClose(app, ~, ~) % 关闭数据传输窗口 app.stopTransfer(); delete(app.dataTransferFig); app.dataTransferFig = []; end %% function createMenus(app) % 文件菜单 fileMenu = uimenu(app.fig, 'Text', '文件'); uimenu(fileMenu, 'Text', '加载数据', 'MenuSelectedFcn', @app.loadDataFile); uimenu(fileMenu, 'Text', '保存数据', 'MenuSelectedFcn', @app.saveData); uimenu(fileMenu, 'Text', '生成报告', 'MenuSelectedFcn', @app.generateReport); uimenu(fileMenu, 'Text', '退出', 'Separator', 'on', 'MenuSelectedFcn', @app.onClose); % 通信菜单 comMenu = uimenu(app.fig, 'Text', '通信'); uimenu(comMenu, 'Text', '连接设备', 'MenuSelectedFcn', @app.connectDevice); uimenu(comMenu, 'Text', '断开连接', 'MenuSelectedFcn', @app.disconnect); uimenu(comMenu, 'Text', '数据传输控制', 'Separator', 'on','MenuSelectedFcn', @app.openTransferWindow); % 视图菜单 viewMenu = uimenu(app.fig, 'Text', '视图'); uimenu(viewMenu, 'Text', '显示总览窗口', 'MenuSelectedFcn', @app.showOverviewWindow); uimenu(viewMenu, 'Text', '显示所有导联窗口', 'MenuSelectedFcn', @app.showAllLeadWindows); uimenu(viewMenu, 'Text', '关闭所有导联窗口', 'MenuSelectedFcn', @app.closeAllLeadWindows); % 设置菜单 settingsMenu = uimenu(app.fig, 'Text', '设置'); uimenu(settingsMenu, 'Text', '通信设置', 'MenuSelectedFcn', @app.setCommunication); uimenu(settingsMenu, 'Text', '患者信息', 'MenuSelectedFcn', @app.setPatientInfo); uimenu(settingsMenu, 'Text', '分析参数', 'MenuSelectedFcn', @app.setAnalysisParams); % 添加新菜单项 transferMenu = uimenu(app.fig, 'Text', '数据通信'); uimenu(transferMenu, 'Text', '打开通信窗口', 'MenuSelectedFcn', @app.openTransferWindow); end function openTransferWindow(app, ~, ~) % 打开数据传输窗口 if isempty(app.dataTransferFig) || ~ishandle(app.dataTransferFig) app.createDataTransferWindow(); else figure(app.dataTransferFig); % 激活现有窗口 end function createDataTransferWindow(app) % 创建数据传输子窗口 app.dataTransferFig = figure('Name', '数据通信', 'NumberTitle', 'off',... 'Position', [300, 300, 600, 400], 'MenuBar', 'none',... 'CloseRequestFcn', @app.onTransferClose); % 文件选择区域 uicontrol('Parent', app.dataTransferFig, 'Style', 'pushbutton',... 'String', '打开文件', 'Position', [20, 350, 80, 30],... 'Callback', @app.openFileForTransfer); app.filePathText = uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '未选择文件', 'Position', [110, 350, 350, 30],... 'HorizontalAlignment', 'left', 'FontSize', 10); % 串口选择区域 uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '选择串口:', 'Position', [20, 300, 80, 25], 'FontSize', 10); app.portDropdown = uicontrol('Parent', app.dataTransferFig, 'Style', 'popup',... 'String', {'无可用串口'}, 'Position', [110, 300, 150, 25],... 'FontSize', 10); % 扫描可用串口 app.scanAvailablePorts(); % 传输控制按钮 app.transferButton = uicontrol('Parent', app.dataTransferFig, 'Style', 'pushbutton',... 'String', '开始传输', 'Position', [280, 300, 100, 30],... 'Callback', @app.toggleTransfer, 'Enable', 'off'); % 状态显示 app.statusTextTransfer = uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '状态: 准备就绪', 'Position', [20, 250, 350, 25],... 'HorizontalAlignment', 'left', 'FontSize', 10); % 进度条 uicontrol('Parent', app.dataTransferFig, 'Style', 'text',... 'String', '传输进度:', 'Position', [20, 200, 80, 25], 'FontSize', 10); app.progressBar = uicontrol('Parent', app.dataTransferFig, 'Style', 'slider',... 'Position', [110, 200, 350, 25], 'Enable', 'off', 'Min', 0, 'Max', 1, 'Value', 0); % 绘图区域 - 显示12导联实时数据 plotPanel = uipanel('Parent', app.dataTransferFig, 'Title', '实时数据',... 'Position', [0.05, 0.05, 0.9, 0.4]); % 创建12导联网格布局 app.transferPlots = gobjects(12, 1); for i = 1:12 row = ceil(i/4); col = mod(i-1,4)+1; ax = subplot(3, 4, i, 'Parent', plotPanel); app.transferPlots(i) = plot(ax, nan, nan, 'b-'); title(ax, app.leadNames{i}); xlabel(ax, '时间(s)'); ylabel(ax, '幅度(mV)'); grid(ax, 'on'); end end function scanAvailablePorts(app) % 扫描系统可用串口 ports = serialportlist("available"); if isempty(ports) set(app.portDropdown, 'String', {'无可用串口'}); set(app.transferButton, 'Enable', 'off'); else set(app.portDropdown, 'String', ports); set(app.transferButton, 'Enable', 'on'); end end function openFileForTransfer(app, ~, ~) % 打开心电数据文件 [filename, pathname] = uigetfile(... {'*.mat;*.csv;*.txt;*.bin', '心电数据文件 (*.mat, *.csv, *.txt, *.bin)'},... '选择心电数据文件'); if isequal(filename, 0) return; end filepath = fullfile(pathname, filename); set(app.filePathText, 'String', filepath); try [~, ~, ext] = fileparts(filepath); switch lower(ext) case '.mat' data = load(filepath); if isfield(data, 'ecgData') app.fileData.ecgData = data.ecgData; app.fileData.timeStamps = data.timeStamps; app.fileData.sampleRate = data.sampleRate; else error('MAT文件格式不支持'); end case {'.csv', '.txt'} data = readmatrix(filepath); if size(data, 2) >= 13 app.fileData.timeStamps = data(:, 1)'; app.fileData.ecgData = data(:, 2:13)'; app.fileData.sampleRate = 1 / mean(diff(app.fileData.timeStamps)); else error('CSV/TXT文件格式错误'); end case '.bin' % 二进制文件解析 (示例实现) fid = fopen(filepath, 'r'); header = fread(fid, 4, 'uint8'); app.fileData.sampleRate = fread(fid, 1, 'single'); numPoints = fread(fid, 1, 'uint32'); numLeads = fread(fid, 1, 'uint8'); data = fread(fid, [numLeads+1, numPoints], 'single'); fclose(fid); app.fileData.timeStamps = data(1, :); app.fileData.ecgData = data(2:end, :); end app.fileData.currentIndex = 1; app.fileData.totalPoints = size(app.fileData.ecgData, 2); set(app.statusTextTransfer, 'String',... sprintf('状态: 已加载 %s (%d 个样本)', filename, app.fileData.totalPoints)); set(app.progressBar, 'Min', 0, 'Max', app.fileData.totalPoints, 'Value', 0); catch ME errordlg(sprintf('文件读取错误: %s', ME.message), '文件错误'); end end function toggleTransfer(app, ~, ~) if app.isTransferring % 停止传输 app.stopTransfer(); set(app.transferButton, 'String', '开始传输'); set(app.statusTextTransfer, 'String', '状态: 传输已停止'); else % 开始传输 if isempty(app.fileData) || isempty(app.fileData.ecgData) errordlg('请先选择数据文件', '传输错误'); return; end ports = get(app.portDropdown, 'String'); selectedIdx = get(app.portDropdown, 'Value'); selectedPort = ports{selectedIdx}; app.startTransfer(selectedPort); set(app.transferButton, 'String', '停止传输'); set(app.statusTextTransfer, 'String', '状态: 传输中...'); end end function startTransfer(app, port) % 创建传输串口 try app.transferSerialPort = serialport(port, app.baudRate,... 'DataBits', app.dataBits,... 'Parity', app.parity,... 'StopBits', app.stopBits); % 创建传输定时器 interval = 1000 / app.fileData.sampleRate; % 按采样率间隔发送 app.transferTimer = timer(... 'ExecutionMode', 'fixedRate',... 'Period', interval,... 'TimerFcn', @(~,~) app.sendDataPacket(),... 'StopFcn', @(~,~) app.cleanupAfterTransfer()); app.isTransferring = true; start(app.transferTimer); catch ME errordlg(sprintf('串口初始化失败: %s', ME.message), '通信错误'); app.isTransferring = false; end end function sendDataPacket(app) % 发送单个数据包 if app.fileData.currentIndex > app.fileData.totalPoints app.stopTransfer(); return; end % 获取当前数据点 idx = app.fileData.currentIndex; timestamp = app.fileData.timeStamps(idx); ecgValues = app.fileData.ecgData(:, idx); % 生成OEM协议数据包 packet = zeros(1, 13, 'single'); packet(1) = timestamp; packet(2:13) = ecgValues; % 转换为字节流 dataBytes = typecast(packet, 'uint8'); % 添加帧头帧尾 fullPacket = [uint8([AA, 55]), dataBytes, uint8([55, AA])]; % 通过串口发送 write(app.transferSerialPort, fullPacket, 'uint8'); % 更新子窗口绘图 app.updateTransferPlots(idx); % 更新进度 app.fileData.currentIndex = idx + 1; set(app.progressBar, 'Value', idx); end function updateTransferPlots(app, idx) % 更新子窗口的12导联绘图 windowSize = 200; % 显示窗口大小 startIdx = max(1, idx - windowSize); endIdx = min(app.fileData.totalPoints, idx + windowSize); timeWindow = app.fileData.timeStamps(startIdx:endIdx); for i = 1:12 if ishandle(app.transferPlots(i)) set(app.transferPlots(i), 'XData', timeWindow,... 'YData', app.fileData.ecgData(i, startIdx:endIdx)); % 自动调整Y轴范围 data = app.fileData.ecgData(i, startIdx:endIdx); dataRange = range(data); if dataRange > 0 ax = get(app.transferPlots(i), 'Parent'); ylim(ax, [min(data)-0.1*dataRange, max(data)+0.1*dataRange]); end end end drawnow; end function stopTransfer(app) % 停止数据传输 if ~isempty(app.transferTimer) && isvalid(app.transferTimer) stop(app.transferTimer); delete(app.transferTimer); app.transferTimer = []; end if ~isempty(app.transferSerialPort) && isvalid(app.transferSerialPort) delete(app.transferSerialPort); app.transferSerialPort = []; end app.isTransferring = false; end function cleanupAfterTransfer(app) % 传输完成后的清理 set(app.transferButton, 'String', '开始传输'); set(app.statusTextTransfer, 'String', '状态: 传输完成'); app.isTransferring = false; end function onTransferClose(app, ~, ~) % 关闭数据传输窗口 app.stopTransfer(); delete(app.dataTransferFig); app.dataTransferFig = []; end function createControlPanel(app) % 创建控制面板 app.controlPanel = uipanel('Parent', app.fig, 'Title', '控制面板',... 'Position', [0.05, 0.05, 0.9, 0.9]); % 通信控制 uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',... 'String', '连接设备', 'Position', [20, 520, 100, 30],... 'Callback', @app.connectDevice); uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',... 'String', '断开连接', 'Position', [140, 520, 100, 30],... 'Callback', @app.disconnect); app.statusText = uicontrol('Parent', app.controlPanel, 'Style', 'text',... 'String', '状态: 未连接', 'Position', [260, 520, 300, 30],... 'HorizontalAlignment', 'left', 'FontSize', 10); % 滤波设置 uicontrol('Parent', app.controlPanel, 'Style', 'text', 'String', '高通滤波(Hz):',... 'Position', [20, 470, 100, 25], 'FontSize', 10); app.highPassEdit = uicontrol('Parent', app.controlPanel, 'Style', 'edit',... 'String', '0.5', 'Position', [130, 470, 60, 25], 'FontSize', 10); uicontrol('Parent', app.controlPanel, 'Style', 'text', 'String', '低通滤波(Hz):',... 'Position', [20, 440, 100, 25], 'FontSize', 10); app.lowPassEdit = uicontrol('Parent', app.controlPanel, 'Style', 'edit',... 'String', '100', 'Position', [130, 440, 60, 25], 'FontSize', 10); uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton', 'String', '应用滤波',... 'Position', [200, 450, 80, 30], 'Callback', @app.applyFilters); % 工频陷波 app.notchCheckbox = uicontrol('Parent', app.controlPanel, 'Style', 'checkbox',... 'String', '50Hz陷波', 'Position', [300, 450, 100, 25], 'Value', 1, 'FontSize', 10); % 数据记录 app.recordButton = uicontrol('Parent', app.controlPanel, 'Style', 'togglebutton',... 'String', '开始记录', 'Position', [20, 380, 100, 30],... 'Callback', @app.toggleRecording); app.saveButton = uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',... 'String', '保存数据', 'Position', [140, 380, 100, 30],... 'Callback', @app.saveData); % 导联选择 uicontrol('Parent', app.controlPanel, 'Style', 'text',... 'String', '导联选择:', 'Position', [20, 340, 80, 25], 'FontSize', 10); app.leadSelect = uicontrol('Parent', app.controlPanel, 'Style', 'popup',... 'String', app.leadNames, 'Position', [110, 340, 100, 25],... 'Callback', @app.onLeadSelected, 'FontSize', 10); % 打开导联窗口按钮 uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',... 'String', '打开导联窗口', 'Position', [230, 340, 120, 30],... 'Callback', @app.openSelectedLeadWindow); % 心率显示 uicontrol('Parent', app.controlPanel, 'Style', 'text',... 'String', '心率:', 'Position', [20, 300, 50, 25], 'FontSize', 10); app.heartRateText = uicontrol('Parent', app.controlPanel, 'Style', 'text',... 'String', '-- bpm', 'Position', [80, 300, 80, 25], 'FontSize', 10); % 数据导航 uicontrol('Parent', app.controlPanel, 'Style', 'text',... 'String', '数据位置:', 'Position', [20, 260, 80, 25], 'FontSize', 10); app.dataSlider = uicontrol('Parent', app.controlPanel, 'Style', 'slider',... 'Position', [110, 260, 300, 25], 'Callback', @app.sliderMoved,... 'Enable', 'off'); app.positionText = uicontrol('Parent', app.controlPanel, 'Style', 'text',... 'String', '0/0', 'Position', [420, 260, 80, 25], 'FontSize', 10); % 视图控制按钮 uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',... 'String', '显示总览窗口', 'Position', [20, 200, 120, 30],... 'Callback', @app.showOverviewWindow); uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',... 'String', '显示所有导联窗口', 'Position', [160, 200, 150, 30],... 'Callback', @app.showAllLeadWindows); uicontrol('Parent', app.controlPanel, 'Style', 'pushbutton',... 'String', '关闭所有导联窗口', 'Position', [330, 200, 150, 30],... 'Callback', @app.closeAllLeadWindows); % 频谱分析 spectrumPanel = uipanel('Parent', app.controlPanel, 'Title', '频谱分析',... 'Position', [0.05, 0.05, 0.9, 0.15]); app.spectrumAx = axes('Parent', spectrumPanel, 'Position', [0.1, 0.2, 0.85, 0.7]); title(app.spectrumAx, 'ECG信号频谱'); xlabel(app.spectrumAx, '频率(Hz)'); ylabel(app.spectrumAx, '幅度(dB)'); grid(app.spectrumAx, 'on'); end function initializeSystem(app) % 初始化系统参数 app.sampleRate = 1000; app.dataLength = 0; app.currentIndex = 1; app.isRecording = false; % 初始化串口参数(默认值需符合OEM协议) app.comPort = 'COM1'; app.baudRate = 115200; app.dataBits = 8; app.parity = 'none'; app.stopBits = 1; % 设置默认患者信息 app.patientInfo = struct(... 'name', '张三', ... 'gender', '男', ... 'age', 45, ... 'id', 'P2023001', ... 'date', datestr(now, 'yyyy-mm-dd')); end %% 窗口管理功能 function showOverviewWindow(app, ~, ~) % 显示总览窗口 if ishandle(app.overviewFig) figure(app.overviewFig); else app.createOverviewWindow(); end end function showAllLeadWindows(app, ~, ~) % 显示所有导联窗口 for i = 1:12 app.createSingleLeadWindow(i); end end function closeAllLeadWindows(app, ~, ~) % 关闭所有导联窗口 for i = 1:12 if ishandle(app.singleLeadFigs(i)) delete(app.singleLeadFigs(i)); end end end function openSelectedLeadWindow(app, ~, ~) % 打开选中的导联窗口 leadIdx = get(app.leadSelect, 'Value'); app.createSingleLeadWindow(leadIdx); end function onLeadSelected(app, src, ~) % 导联选择回调 leadIdx = get(src, 'Value'); app.updateSpectrumForLead(leadIdx); end function onOverviewClose(app, ~, ~) % 总览窗口关闭回调 delete(app.overviewFig); app.overviewFig = gobjects(1); end function onSingleLeadClose(app, leadIndex) % 单导联窗口关闭回调 delete(app.singleLeadFigs(leadIndex)); app.singleLeadFigs(leadIndex) = gobjects(1); end %% 通信协议实现 function setCommunication(app, ~, ~) % 串口设置对话框 prompt = {'串口号:', '波特率:', '数据位:', '校验位:', '停止位:'}; dlgtitle = '串口设置'; dims = [1 20]; definput = {app.comPort, num2str(app.baudRate), num2str(app.dataBits), ... app.parity, num2str(app.stopBits)}; answer = inputdlg(prompt, dlgtitle, dims, definput); if ~isempty(answer) app.comPort = answer{1}; app.baudRate = str2double(answer{2}); app.dataBits = str2double(answer{3}); app.parity = answer{4}; app.stopBits = str2double(answer{5}); % 验证波特率是否合法(示例:仅允许常见波特率) validBaudRates = [9600, 19200, 38400, 57600, 115200, 230400]; if ~any(app.baudRate == validBaudRates) app.baudRate = 115200; % 默认恢复为常用值 warndlg('波特率设置不合法,已恢复为115200', '设置警告'); end set(app.statusText, 'String', '串口设置已更新'); end end function connectDevice(app, ~, ~) try % 关闭现有连接 if ~isempty(app.serialPort) && isvalid(app.serialPort) fclose(app.serialPort); delete(app.serialPort); end % 创建串口对象(根据OEM协议配置参数) app.serialPort = serialport(app.comPort, app.baudRate, ... 'DataBits', app.dataBits, ... 'Parity', app.parity, ... 'StopBits', app.stopBits); % 设置数据接收回调(OEM协议通常以特定字节结尾,如CR/LF或固定帧长) configureCallback(app.serialPort, "terminator", "linefeed", ... @(src, ~) app.serialCallback(src)); % 初始化数据缓冲区 app.ecgData = zeros(12, 10000); app.filteredData = zeros(12, 10000); app.timeStamps = zeros(1, 10000); app.currentIndex = 1; app.dataLength = 0; % 启用数据导航 set(app.dataSlider, 'Enable', 'on'); set(app.statusText, 'String', ['已连接到串口: ' app.comPort ' (波特率: ' num2str(app.baudRate) ')']); app.isConnected = true; catch ME errordlg(['串口连接失败: ' ME.message], '连接错误'); app.isConnected = false; end end function disconnect(app, ~, ~) if ~isempty(app.serialPort) && isvalid(app.serialPort) fclose(app.serialPort); delete(app.serialPort); app.serialPort = []; set(app.statusText, 'String', '已断开串口连接'); app.isConnected = false; end end function serialCallback(app, src) % OEM协议帧结构: [AA 55][2字节序号][12×4字节浮点数][2字节CRC][FF] FRAME_HEADER = uint8([170, 85]); % AA 55 FRAME_TAIL = uint8(255); % FF while src.NumBytesAvailable >= 64 % 完整帧大小 % 查找帧头 header = read(src, 2, 'uint8'); if ~isequal(header, FRAME_HEADER) read(src, 1, 'uint8'); % 丢弃1字节后重试 continue; end % 读取完整帧 frame = read(src, 62, 'uint8'); % 2+60=62字节 tail = frame(end); if tail ~= FRAME_TAIL warning('帧尾错误: %s', dec2hex(tail)); continue; end % 提取数据 seqBytes = frame(1:2); dataBytes = frame(3:50); % 12×4=48字节 crcReceived = typecast(frame(51:52), 'uint16'); % CRC验证 crcCalc = app.calculateCRC([seqBytes; dataBytes]); if crcReceived ~= crcCalc warning('CRC校验失败: 接收%X vs 计算%X', crcReceived, crcCalc); continue; end % 解析数据 seq = typecast(seqBytes, 'uint16'); ecgValues = typecast(dataBytes, 'single'); % 存储数据(添加时间戳) if app.dataLength < size(app.ecgData, 2) app.dataLength = app.dataLength + 1; app.timeStamps(app.dataLength) = posixtime(datetime('now')); app.ecgData(:, app.dataLength) = ecgValues; end end end % 新增的CRC计算函数 function crc = calculateCRC(~, data) crc = uint16(0); poly = uint16(hex2dec('1021')); % CRC-16/CCITT-FALSE for i = 1:length(data) crc = bitxor(crc, bitshift(uint16(data(i)),8)); for j = 1:8 if bitand(crc, hex2dec('8000')) crc = bitxor(bitshift(crc,1), poly); else crc = bitshift(crc,1); end end end end %% 数据处理模块 function realTimeFiltering(app, idx) % 实时滤波处理 if idx == 1 % 初始化滤波数据 app.filteredData(:, idx) = app.ecgData(:, idx); return; end % 获取滤波参数 hpFreq = str2double(get(app.highPassEdit, 'String')); lpFreq = str2double(get(app.lowPassEdit, 'String')); useNotch = get(app.notchCheckbox, 'Value'); for i = 1:12 % 应用高通滤波 if ~isnan(hpFreq) && hpFreq > 0 [b, a] = butter(2, hpFreq/(app.sampleRate/2), 'high'); if idx > 10 segment = app.ecgData(i, max(1, idx-10):idx); filteredSeg = filtfilt(b, a, segment); app.filteredData(i, idx) = filteredSeg(end); else app.filteredData(i, idx) = app.ecgData(i, idx); end else app.filteredData(i, idx) = app.ecgData(i, idx); end % 应用低通滤波 if ~isnan(lpFreq) && lpFreq > 0 [b, a] = butter(4, lpFreq/(app.sampleRate/2), 'low'); if idx > 10 segment = app.filteredData(i, max(1, idx-10):idx); filteredSeg = filtfilt(b, a, segment); app.filteredData(i, idx) = filteredSeg(end); end end % 应用50Hz陷波 if useNotch wo = 50/(app.sampleRate/2); bw = wo/10; [b, a] = iirnotch(wo, bw); if idx > 10 segment = app.filteredData(i, max(1, idx-10):idx); filteredSeg = filtfilt(b, a, segment); app.filteredData(i, idx) = filteredSeg(end); end end end end function applyFilters(app, ~, ~) % 应用全局滤波 if app.dataLength > 0 % 获取滤波参数 hpFreq = str2double(get(app.highPassEdit, 'String')); lpFreq = str2double(get(app.lowPassEdit, 'String')); useNotch = get(app.notchCheckbox, 'Value'); for i = 1:12 % 高通滤波 if ~isnan(hpFreq) && hpFreq > 0 [b, a] = butter(2, hpFreq/(app.sampleRate/2), 'high'); app.filteredData(i, 1:app.dataLength) = ... filtfilt(b, a, app.ecgData(i, 1:app.dataLength)); end % 低通滤波 if ~isnan(lpFreq) && lpFreq > 0 [b, a] = butter(4, lpFreq/(app.sampleRate/2), 'low'); app.filteredData(i, 1:app.dataLength) = ... filtfilt(b, a, app.filteredData(i, 1:app.dataLength)); end % 50Hz陷波 if useNotch wo = 50/(app.sampleRate/2); bw = wo/10; [b, a] = iirnotch(wo, bw); app.filteredData(i, 1:app.dataLength) = ... filtfilt(b, a, app.filteredData(i, 1:app.dataLength)); end end % 更新显示 app.updatePlots(); app.updateSpectrum(); set(app.statusText, 'String', '全局滤波已应用'); end end %% 实时分析模块 function realTimeAnalysis(app) % 实时分析ECG信号 if app.dataLength > 100 % 在II导联检测R波 leadIndex = 2; data = app.filteredData(leadIndex, max(1, app.dataLength-500):app.dataLength); % 使用Pan-Tompkins算法检测R波 [~, qrs_i_raw] = app.pan_tompkins(data, app.sampleRate); % 计算心率 if length(qrs_i_raw) > 1 rrIntervals = diff(qrs_i_raw) / app.sampleRate; app.heartRate = 60 / mean(rrIntervals); set(app.heartRateText, 'String', sprintf('%.1f bpm', app.heartRate)); % 更新图形标记 hold(app.ax(leadIndex), 'on'); if ~isempty(app.rPeakMarkers) && isvalid(app.rPeakMarkers) delete(app.rPeakMarkers); end % 计算实际时间位置 timeOffset = app.timeStamps(max(1, app.dataLength-500)); timePos = app.timeStamps(qrs_i_raw + max(1, app.dataLength-500) - 1); app.rPeakMarkers = plot(app.ax(leadIndex),... timePos, data(qrs_i_raw), 'ro', 'MarkerSize', 6, 'MarkerFaceColor', 'r'); hold(app.ax(leadIndex), 'off'); end end end function [qrs_amp_raw, qrs_i_raw] = pan_tompkins(~, ecg, fs) % Pan-Tompkins R波检测算法 % 1. 带通滤波 (5-15 Hz) f1 = 5/(fs/2); f2 = 15/(fs/2); [b, a] = butter(1, [f1, f2], 'bandpass'); ecg_filtered = filtfilt(b, a, ecg); % 2. 微分 diff_ecg = diff(ecg_filtered); diff_ecg = [diff_ecg(1), diff_ecg]; % 3. 平方 sqr_ecg = diff_ecg .^ 2; % 4. 移动平均窗口积分 window_size = round(0.150 * fs); integrated_ecg = movmean(sqr_ecg, window_size); % 5. 自适应阈值检测 max_h = max(integrated_ecg); threshold = 0.2 * max_h; % 6. 寻找R波位置 [qrs_amp_raw, qrs_i_raw] = findpeaks(integrated_ecg,... 'MinPeakHeight', threshold,... 'MinPeakDistance', round(0.2*fs)); end %% 数据管理 function toggleRecording(app, src, ~) % 开始/停止记录数据 if src.Value app.isRecording = true; set(src, 'String', '停止记录'); set(app.statusText, 'String', '记录中...'); % 初始化记录缓冲区 app.ecgData = zeros(12, 10000); app.filteredData = zeros(12, 10000); app.timeStamps = zeros(1, 10000); app.currentIndex = 1; app.dataLength = 0; else app.isRecording = false; set(src, 'String', '开始记录'); set(app.statusText, 'String', '记录已停止'); end end function loadDataFile(app, ~, ~) % 加载ECG数据文件 [filename, pathname] = uigetfile({'*.mat;*.csv;*.txt', '数据文件 (*.mat, *.csv, *.txt)'},... '选择ECG数据文件'); if isequal(filename, 0) return; end filepath = fullfile(pathname, filename); [~, ~, ext] = fileparts(filepath); try h = waitbar(0, '加载数据中...', 'Name', '数据处理'); switch lower(ext) case '.mat' data = load(filepath); waitbar(0.3, h); if isfield(data, 'ecgData') app.ecgData = data.ecgData; app.filteredData = data.filteredData; app.timeStamps = data.timeStamps; app.sampleRate = data.sampleRate; else error('无法识别MAT文件中的数据格式'); end case {'.csv', '.txt'} data = readmatrix(filepath); waitbar(0.3, h); if size(data, 2) >= 13 app.timeStamps = data(:, 1)'; app.ecgData = data(:, 2:13)'; app.sampleRate = 1 / mean(diff(app.timeStamps)); else error('CSV/TXT文件应包含时间戳和12导联数据'); end end app.dataLength = size(app.ecgData, 2); app.currentIndex = app.dataLength; % 应用滤波 waitbar(0.7, h, '应用滤波...'); app.applyFilters(); % 更新界面 waitbar(0.9, h, '更新界面...'); app.updatePlots(); app.updateSpectrum(); % 配置导航滑块 set(app.dataSlider, 'Min', 0, 'Max', 1, 'Value', 1,... 'SliderStep', [100/app.dataLength, 1000/app.dataLength],... 'Enable', 'on'); set(app.positionText, 'String',... sprintf('%d/%d', app.dataLength, size(app.ecgData, 2))); set(app.statusText, 'String',... sprintf('已加载: %s (%d个样本)', filename, app.dataLength)); waitbar(1, h, '完成!'); pause(0.5); delete(h); catch ME errordlg(sprintf('加载失败: %s', ME.message), '文件错误'); end end function saveData(app, ~, ~) % 保存ECG数据 if app.dataLength == 0 errordlg('没有可保存的数据', '保存错误'); return; end [filename, pathname] = uiputfile({'*.mat', 'MAT文件 (*.mat)'; '*.csv', 'CSV文件 (*.csv)'},... '保存ECG数据', 'ecg_data.mat'); if isequal(filename, 0) return; end filepath = fullfile(pathname, filename); [~, ~, ext] = fileparts(filepath); try app.ecgData = app.ecgData(:, 1:app.dataLength); app.filteredData = app.filteredData(:, 1:app.dataLength); app.timeStamps = app.timeStamps(1:app.dataLength); app.sampleRate = app.sampleRate; switch lower(ext) case '.mat' save(filepath, 'ecgData', 'filteredData', 'timeStamps', 'sampleRate'); case '.csv' dataToSave = [app.timeStamps', app.ecgData']; writematrix(dataToSave, filepath); end set(app.statusText, 'String', sprintf('数据已保存至: %s', filename)); catch ME errordlg(sprintf('保存失败: %s', ME.message), '保存错误'); end end %% 显示更新 function updatePlots(app) % 更新所有窗口的心电图显示 if app.dataLength > 0 windowSize = 500; % 显示窗口大小 startIdx = max(1, app.currentIndex - windowSize); endIdx = min(app.dataLength, app.currentIndex + windowSize); timeWindow = app.timeStamps(startIdx:endIdx); % 更新总览窗口 if ishandle(app.overviewFig) for i = 1:12 if ishandle(app.overviewPlots(i)) set(app.overviewPlots(i), 'XData', timeWindow,... 'YData', app.filteredData(i, startIdx:endIdx)); % 自动调整Y轴范围 dataRange = range(app.filteredData(i, startIdx:endIdx)); if dataRange > 0 ax = get(app.overviewPlots(i), 'Parent'); ylim(ax, [min(app.filteredData(i, startIdx:endIdx)) - 0.1*dataRange,... max(app.filteredData(i, startIdx:endIdx)) + 0.1*dataRange]); end end end drawnow; end % 更新单导联窗口 for i = 1:12 if ishandle(app.singleLeadFigs(i)) && ishandle(app.leadPlots(i)) set(app.leadPlots(i), 'XData', timeWindow,... 'YData', app.filteredData(i, startIdx:endIdx)); % 自动调整Y轴范围 dataRange = range(app.filteredData(i, startIdx:endIdx)); if dataRange > 0 ax = get(app.leadPlots(i), 'Parent'); ylim(ax, [min(app.filteredData(i, startIdx:endIdx)) - 0.1*dataRange,... max(app.filteredData(i, startIdx:endIdx)) + 0.1*dataRange]); end drawnow; end end end end function updateSpectrumForLead(app, leadIndex) % 更新指定导联的频谱分析 if app.dataLength > 0 && leadIndex >= 1 && leadIndex <= 12 signal = app.filteredData(leadIndex, max(1, app.dataLength-2000):app.dataLength); % 计算FFT N = length(signal); if N < 10, return; end f = (0:N-1)*(app.sampleRate/N); Y = fft(signal); P2 = abs(Y/N); P1 = P2(1:floor(N/2)+1); P1(2:end-1) = 2*P1(2:end-1); f = f(1:floor(N/2)+1); % 更新频谱分析图 plot(app.spectrumAx, f, 20*log10(P1), 'b'); title(app.spectrumAx, sprintf('导联 %s 频谱', app.leadNames{leadIndex})); xlabel(app.spectrumAx, '频率 (Hz)'); ylabel(app.spectrumAx, '幅度 (dB)'); xlim(app.spectrumAx, [0, min(150, app.sampleRate/2)]); grid(app.spectrumAx, 'on'); end end function showSpectrumForLead(app, leadIndex) % 在单独窗口中显示导联频谱 if app.dataLength > 0 && leadIndex >= 1 && leadIndex <= 12 figName = sprintf('导联 %s 频谱分析', app.leadNames{leadIndex}); app.fig = figure('Name', figName, 'NumberTitle', 'off',... 'Position', [200, 200, 600, 400]); ax = axes('Parent', app.fig, 'Position', [0.1, 0.15, 0.85, 0.75]); signal = app.filteredData(leadIndex, max(1, app.dataLength-2000):app.dataLength); N = length(signal); f = (0:N-1)*(app.sampleRate/N); Y = fft(signal); P2 = abs(Y/N); P1 = P2(1:floor(N/2)+1); P1(2:end-1) = 2*P1(2:end-1); f = f(1:floor(N/2)+1); plot(ax, f, 20*log10(P1), 'b'); title(ax, sprintf('导联 %s 频谱分析', app.leadNames{leadIndex})); xlabel(ax, '频率 (Hz)'); ylabel(ax, '幅度 (dB)'); xlim(ax, [0, min(150, app.sampleRate/2)]); grid(ax, 'on'); end end function detectRPeaksForLead(app, leadIndex) % 检测指定导联的R波 if app.dataLength > 0 && leadIndex >= 1 && leadIndex <= 12 data = app.filteredData(leadIndex, max(1, app.dataLength-2000):app.dataLength); % 使用Pan-Tompkins算法检测R波 [~, qrs_i_raw] = app.pan_tompkins(data, app.sampleRate); % 在图形上标记R波 if ishandle(app.singleLeadFigs(leadIndex)) app.fig = app.singleLeadFigs(leadIndex); ax = findobj(app.fig, 'Type', 'axes'); % 清除旧标记 oldMarkers = findobj(ax, 'Tag', 'RPeakMarker'); if ~isempty(oldMarkers) delete(oldMarkers); end % 添加新标记 hold(ax, 'on'); plot(ax, app.timeStamps(qrs_i_raw + max(1, app.dataLength-2000) - 1),... data(qrs_i_raw), 'ro', 'MarkerSize', 8, 'MarkerFaceColor', 'r',... 'Tag', 'RPeakMarker'); hold(ax, 'off'); % 计算并显示心率 if length(qrs_i_raw) > 1 rrIntervals = diff(qrs_i_raw) / app.sampleRate; app.heartRate = 60 / mean(rrIntervals); title(ax, sprintf('导联 %s - 心率: %.1f bpm',... app.leadNames{leadIndex}, app.heartRate)); end end end end %% 报告生成 function setPatientInfo(app, ~, ~) % 设置患者信息 prompt = {'姓名:', '性别:', '年龄:', '患者ID:'}; dlgtitle = '患者信息'; dims = [1 25]; definput = {app.patientInfo.name, app.patientInfo.gender,... num2str(app.patientInfo.age), app.patientInfo.id}; answer = inputdlg(prompt, dlgtitle, dims, definput); if ~isempty(answer) app.patientInfo.name = answer{1}; app.patientInfo.gender = answer{2}; app.patientInfo.age = str2double(answer{3}); app.patientInfo.id = answer{4}; app.patientInfo.date = datestr(now, 'yyyy-mm-dd HH:MM'); end end function generateReport(app, ~, ~) % 生成心电图报告 if app.dataLength == 0 errordlg('没有数据可生成报告', '报告错误'); return; end [filename, pathname] = uiputfile('*.pdf', '保存心电图报告', 'ECG_Report.pdf'); if filename == 0 return; end pdfFile = fullfile(pathname, filename); app.exportToPDF(pdfFile); msgbox(sprintf('心电图报告已保存至:\n%s', pdfFile), '报告生成'); end function exportToPDF(app, filename) % 导出PDF报告 app.fig = figure('Visible', 'off', 'Position', [100, 100, 1200, 900]); % 报告标题 uicontrol('Style', 'text', 'String', '心电图报告',... 'Position', [400, 850, 400, 40], 'FontSize', 20, 'FontWeight', 'bold'); % 患者信息 app.patientInfo = {sprintf('姓名: %s', app.patientInfo.name),... sprintf('性别: %s', app.patientInfo.gender),... sprintf('年龄: %d', app.patientInfo.age),... sprintf('ID: %s', app.patientInfo.id),... sprintf('检查日期: %s', app.patientInfo.date)}; uicontrol('Style', 'text', 'String', app.patientInfo,... 'Position', [50, 780, 300, 100], 'FontSize', 12, 'HorizontalAlignment', 'left'); % 分析结果 analysisInfo = {sprintf('心率: %.1f bpm', app.heartRate),... sprintf('采样率: %d Hz', app.sampleRate),... sprintf('数据长度: %.1f 秒', app.timeStamps(app.dataLength))}; uicontrol('Style', 'text', 'String', analysisInfo,... 'Position', [50, 680, 300, 60], 'FontSize', 12, 'HorizontalAlignment', 'left'); % 绘制12导联心电图 for i = 1:12 row = ceil(i/3); col = mod(i-1,3)+1; ax = subplot(4,3,i, 'Parent', app.fig); if app.dataLength > 0 % 显示完整数据 plot(ax, app.timeStamps(1:app.dataLength),... app.filteredData(i, 1:app.dataLength), 'b-'); end title(ax, app.leadNames{i}); xlabel(ax, '时间(s)'); ylabel(ax, '幅度(mV)'); grid(ax, 'on'); end % 导出为PDF exportgraphics(app.fig, filename, 'ContentType', 'vector'); close(app.fig); end %% 系统控制 function onClose(app, ~, ~) % 关闭应用程序 app.disconnect(); % 关闭所有窗口 if ishandle(app.overviewFig) delete(app.overviewFig); end for i = 1:12 if ishandle(app.singleLeadFigs(i)) delete(app.singleLeadFigs(i)); end end delete(app.fig); end end end end % 辅助函数 function result = iff(condition, trueValue, falseValue) if condition result = trueValue; else result = falseValue; end end
最新发布
06-15
<?xml version="1.0" encoding="UTF-8"?> <ui version="4.0"> <class>MainWindow</class> <widget class="QMainWindow" name="MainWindow"> <property name="geometry"> <rect> <x>0</x> <y>0</y> <width>800</width> <height>600</height> </rect> </property> <property name="windowTitle"> <string>MainWindow</string> </property> <widget class="QWidget" name="centralwidget"> <layout class="QVBoxLayout" name="verticalLayout"> <item> <widget class="QPlainTextEdit" name="plainTextEdit"/> </item> </layout> </widget> <widget class="QStatusBar" name="statusbar"/> <widget class="QMenuBar" name="menubar"> <property name="geometry"> <rect> <x>0</x> <y>0</y> <width>800</width> <height>29</height> </rect> </property> <widget class="QMenu" name="FileMenu"> <property name="title"> <string>文件</string> </property> <addaction name="NewFile"/> <addaction name="OpenFile"/> <addaction name="SaveFile"/> <addaction name="SaveAsFile"/> <addaction name="ExitFile"/> </widget> <widget class="QMenu" name="menu_2"> <property name="title"> <string>编辑</string> </property> <addaction name="CutAct"/> <addaction name="CopyAct"/> <addaction name="PasteAct"/> <addaction name="FontAct"/> </widget> <addaction name="FileMenu"/> <addaction name="menu_2"/> </widget> <action name="NewFile"> <property name="text"> <string>新建标签页</string> </property> </action> <action name="action_2"> <property name="text"> <string>新建窗口</string> </property> </action> <action name="OpenFile"> <property name="text"> <string>打开</string> </property> </action> <action name="action_4"> <property name="text"> <string>最近使用</string> </property> </action> <action name="SaveFile"> <property name="text"> <string>保存</string> </property> </action> <action name="SaveAsFile"> <property name="text"> <string>另存为</string> </property> </action> <action name="action_7"> <property name="text"> <string>全部保存</string> </property> </action> <action name="action_8"> <property name="text"> <string>关闭选项卡</string> </property> </action> <action name="action_9"> <property name="text"> <string>关闭窗口</string> </property> </action> <action name="ExitFile"> <property name="text"> <string>退出</string> </property> </action> <action name="Cut"> <property name="text"> <string>撤销</string> </property> </action> <action name="CutAct"> <property name="text"> <string>剪切</string> </property> </action> <action name="CopyAct"> <property name="text"> <string>复制</string> </property> </action> <action name="PasteAct"> <property name="text"> <string>粘贴</string> </property> </action> <action name="action_15"> <property name="text"> <string>删除</string> </property> </action> <action name="action_16"> <property name="text"> <string>查找</string> </property> </action> <action name="action_17"> <property name="text"> <string>查找下一个</string> </property> </action> <action name="action_18"> <property name="text"> <string>查找上一个</string> </property> </action> <action name="action_19"> <property name="text"> <string>替换</string> </property> </action> <action name="action_20"> <property name="text"> <string>全选</string> </property> </action> <action name="action_21"> <property name="text"> <string>日期/时间</string> </property> </action> <action name="FontAct"> <property name="text"> <string>字体</string> </property> </action> <action name="action_23"> <property name="text"> <string>缩放</string> </property> </action> <action name="action_24"> <property name="text"> <string>状态栏</string> </property> </action> <action name="action_25"> <property name="text"> <string>自动换行 </string> </property> </action> </widget> <resources/> <connections/> </ui> ui部分如上 #ifndef MAINWINDOW_H #define MAINWINDOW_H #include <QTextEdit> #include <QFileDialog> #include <QMessageBox> #include <QFontDialog> #include <QCloseEvent> #include <QMainWindow> QT_BEGIN_NAMESPACE namespace Ui { class MainWindow; } QT_END_NAMESPACE class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent = nullptr); ~MainWindow(); public slots: void newFile(); void openFile(); bool saveFile(); bool saveAs(); void setFont(); private: Ui::MainWindow *ui; void createActions(); void createMenus(); void createToolBars(); }; #endif // MAINWINDOW_H mainwindow.h部分如上 #include "mainwindow.h" #include <QApplication> int main(int argc, char *argv[]) { QApplication a(argc, argv); MainWindow w; w.show(); return a.exec(); }main.cpp如上 #include "mainwindow.h" #include "ui_mainwindow.h" #include <QDebug> MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui->setupUi(this); //信号处理函数 connect(ui->OpenFile,&QAction::triggered,this,&MainWindow::openFile); } MainWindow::~MainWindow() { delete ui; } void MainWindow::openFile() { qInfo() << "打开文件"; } void MainWindow::newFile() { } bool MainWindow::saveFile() { } bool MainWindow::saveAs() { } void MainWindow::setFont() { } void MainWindow::createActions() { } void MainWindow::createMenus() { } void MainWindow::createToolBars() { }帮我完成mainwindows.cpp代码的编写,使其能在qt上运行
05-12
根据以下代码介绍一下实现的界面 JMenu menuOption, mnuSet, mnuHelp; public void createMenus() { //选项子菜单 menuOption = new JMenu("选项(O)"); menuOption.setMnemonic('O');//设置热键 miReset = new JMenuItem("重置"); miPrev = new JMenuItem("上一关"); miNext = new JMenuItem("下一关"); miSelect = new JMenuItem("选择关卡"); miExit = new JMenuItem("退出"); miBack = new JMenuItem("撤销一步"); menuOption.add(miReset); menuOption.add(miPrev); menuOption.add(miNext); menuOption.add(miSelect); menuOption.add(miBack); menuOption.addSeparator();//添加分隔条 menuOption.add(miExit); //音乐子菜单 mnuSet = new JMenu("设置(S)"); mnuSet.setMnemonic('S');//设置热键 miMusic1 = new JMenuItem(sMusic[0]); miMusic2 = new JMenuItem(sMusic[1]); ButtonGroup bg = new ButtonGroup(); bg.add(miMusic1); bg.add(miMusic2); //帮助子菜单 //默认选第一首 setMenuState(0); mnuSet.add(miMusic1); mnuSet.add(miMusic2); mnuHelp = new JMenu("帮助(H)"); mnuHelp.setMnemonic('H'); miHelp = new JMenuItem("关于我们……"); mnuHelp.add(miHelp); //监听 miReset.addActionListener(this); miPrev.addActionListener(this); miNext.addActionListener(this); miBack.addActionListener(this); miSelect.addActionListener(this); miExit.addActionListener(this); miMusic1.addActionListener(this); miMusic2.addActionListener(this); miHelp.addActionListener(this); menuBar = new JMenuBar(); menuBar.add(menuOption); menuBar.add(mnuSet); menuBar.add(mnuHelp); setJMenuBar(menuBar); }
07-08
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值