好友
阅读权限10
听众
最后登录1970-1-1
|
import javax.swing.*;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.DefaultTableModel;
import javax.swing.table.TableCellRenderer;
import java.awt.*;
import java.awt.event.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.text.DecimalFormat;
import java.util.*;
import java.util.Timer;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.GZIPInputStream;
public class StockMonitorApp {
private final JFrame frame;
private final JTable stockTable;
private final DefaultTableModel tableModel;
private final java.util.List<String> stockCodes = new java.util.ArrayList<>();
private final JLabel statusLabel;
private SystemTray systemTray;
private TrayIcon trayIcon;
private java.util.List<Stock> currentStocks = new java.util.ArrayList<>();
private boolean sortAscending = false;
// 配置参数(涨跌幅阈值)
private double changeThreshold = 5.0;
private Color highChangeColor = Color.RED;
private Color normalChangeColor = Color.BLACK;
// 字号配置
private int currentFontSize = 12;
private final int MIN_FONT_SIZE = 8;
private final int MAX_FONT_SIZE = 16;
private final int FONT_STEP = 2;
// 格式化数字显示
private static final DecimalFormat PRICE_FORMAT = new DecimalFormat("#.00");
private static final DecimalFormat CHANGE_FORMAT = new DecimalFormat("0.00");
private static final DecimalFormat PERCENT_FORMAT = new DecimalFormat("0.00");
private static final DecimalFormat AMOUNT_FORMAT = new DecimalFormat("#,##0.00");
// 网络配置参数
private static final int BASE_CONNECT_TIMEOUT = 10000;
private static final int BASE_READ_TIMEOUT = 15000;
private static final int MAX_RETRIES = 3;
private static final int INITIAL_RETRY_DELAY = 500;
// 浏览器请求头
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
private static final String REFERER = "https://finance.sina.cn/";
private static final String ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7";
private static final String ACCEPT_ENCODING = "gzip, deflate, br";
private static final String ACCEPT_LANGUAGE = "zh-CN,zh;q=0.9,en;q=0.8";
// 配置文件路径
private static final String CONFIG_FILE = "stock_codes.txt";
private static final String SETTINGS_FILE = "settings.properties";
public StockMonitorApp() {
// 加载配置
loadSettings();
// 初始化窗口
frame = new JFrame("自选股监控(稳定版)");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(900, 550);
frame.setLocationRelativeTo(null);
// 表格列名:换手率改为成交额
String[] columnNames = {"代码/名称", "最新价", "涨跌幅(%)", "成交额(万元)", "涨幅百分位(%)", "跌幅百分位(%)", "昨收"};
tableModel = new DefaultTableModel(columnNames, 0) {
@Override
public boolean isCellEditable(int row, int column) {
return false;
}
};
stockTable = new JTable(tableModel);
customizeTableRenderer();
// 设置右键菜单
setupTableRightClickMenu();
// 操作按钮
JButton sortButton = new JButton("按涨跌幅降序排序");
sortButton.addActionListener(e -> sortStocksByChange());
JButton settingsButton = new JButton("设置阈值");
settingsButton.addActionListener(e -> showSettingsDialog());
JButton fontPlusButton = new JButton("字号+");
fontPlusButton.addActionListener(e -> adjustFontSize(true));
JButton fontMinusButton = new JButton("字号-");
fontMinusButton.addActionListener(e -> adjustFontSize(false));
// 面板布局
JPanel topPanel = new JPanel();
topPanel.setLayout(new FlowLayout(FlowLayout.LEFT, 10, 5));
topPanel.add(new JLabel("操作:"));
topPanel.add(sortButton);
topPanel.add(settingsButton);
topPanel.add(new JLabel(" | 字号:"));
topPanel.add(fontMinusButton);
topPanel.add(fontPlusButton);
JScrollPane scrollPane = new JScrollPane(stockTable);
statusLabel = new JLabel(String.format("准备就绪. 涨跌幅阈值:%s%%,当前字号:%d号 (Alt+F6 隐藏/显示)",
changeThreshold, currentFontSize));
statusLabel.setHorizontalAlignment(SwingConstants.LEFT);
statusLabel.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10));
JPanel mainPanel = new JPanel();
mainPanel.setLayout(new BorderLayout());
mainPanel.add(topPanel, BorderLayout.NORTH);
mainPanel.add(scrollPane, BorderLayout.CENTER);
mainPanel.add(statusLabel, BorderLayout.SOUTH);
frame.add(mainPanel);
// 初始化功能
initializeStockCodes();
SwingUtilities.invokeLater(() -> statusLabel.setText(String.format("首次加载数据中...涨跌幅阈值:%s%%,当前字号:%d号",
changeThreshold, currentFontSize)));
fetchAndUpdateStockData();
startAutoRefresh();
setupSystemTray();
setupGlobalHotkey();
frame.setVisible(true);
}
/**
* 设置表格右键菜单
*/
private void setupTableRightClickMenu() {
JPopupMenu rightClickMenu = new JPopupMenu();
// 删除股票菜单项
JMenuItem deleteItem = new JMenuItem("删除选中股票");
deleteItem.addActionListener(e -> deleteSelectedStock());
// 新增股票菜单项
JMenuItem addItem = new JMenuItem("新增股票");
addItem.addActionListener(e -> addNewStock());
// 刷新数据菜单项
JMenuItem refreshItem = new JMenuItem("手动刷新数据");
refreshItem.addActionListener(e -> {
statusLabel.setText("手动刷新中...");
fetchAndUpdateStockData();
});
rightClickMenu.add(deleteItem);
rightClickMenu.add(addItem);
rightClickMenu.addSeparator();
rightClickMenu.add(refreshItem);
// 为表格添加右键菜单事件
stockTable.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (SwingUtilities.isRightMouseButton(e)) {
int row = stockTable.rowAtPoint(e.getPoint());
if (row >= 0) {
stockTable.setRowSelectionInterval(row, row);
rightClickMenu.show(stockTable, e.getX(), e.getY());
}
}
}
@Override
public void mouseReleased(MouseEvent e) {
if (SwingUtilities.isRightMouseButton(e)) {
int row = stockTable.rowAtPoint(e.getPoint());
if (row >= 0) {
stockTable.setRowSelectionInterval(row, row);
}
}
}
});
}
/**
* 删除选中的股票
*/
private void deleteSelectedStock() {
int selectedRow = stockTable.getSelectedRow();
if (selectedRow == -1) {
JOptionPane.showMessageDialog(frame, "请先选中要删除的股票!", "提示", JOptionPane.INFORMATION_MESSAGE);
return;
}
// 获取选中股票的代码
String stockInfo = (String) tableModel.getValueAt(selectedRow, 0);
String stockCode = stockInfo.split(" / ")[0];
int confirm = JOptionPane.showConfirmDialog(frame,
"确定要删除股票:" + stockInfo + " 吗?",
"确认删除", JOptionPane.YES_NO_OPTION);
if (confirm == JOptionPane.YES_OPTION) {
// 从列表中移除
stockCodes.remove(stockCode);
// 更新配置文件
saveStockCodesToFile();
// 刷新表格
fetchAndUpdateStockData();
statusLabel.setText("已删除股票:" + stockInfo + ",配置文件已更新");
}
}
/**
* 新增股票
*/
private void addNewStock() {
JDialog dialog = new JDialog(frame, "新增股票", true);
dialog.setSize(350, 180);
dialog.setLocationRelativeTo(frame);
dialog.setLayout(new GridLayout(4, 2, 10, 10));
dialog.setResizable(false);
JLabel codeLabel = new JLabel("股票代码(如:sh600519):");
JTextField codeField = new JTextField();
JLabel nameLabel = new JLabel("股票名称(可选):");
JTextField nameField = new JTextField();
JButton okButton = new JButton("确定添加");
JButton cancelButton = new JButton("取消");
okButton.addActionListener(e -> {
String code = codeField.getText().trim().toLowerCase();
if (code.isEmpty()) {
JOptionPane.showMessageDialog(dialog, "股票代码不能为空!", "错误", JOptionPane.ERROR_MESSAGE);
return;
}
// 验证代码格式
if (!code.startsWith("sh") && !code.startsWith("sz")) {
int confirm = JOptionPane.showConfirmDialog(dialog,
"股票代码格式应为shxxx或szxxx,是否自动补全为sh" + code + "?",
"格式提示", JOptionPane.YES_NO_CANCEL_OPTION);
if (confirm == JOptionPane.YES_OPTION) {
code = "sh" + code;
} else if (confirm == JOptionPane.NO_OPTION) {
code = "sz" + code;
} else {
return;
}
}
if (stockCodes.contains(code)) {
JOptionPane.showMessageDialog(dialog, "该股票已存在!", "提示", JOptionPane.INFORMATION_MESSAGE);
return;
}
// 测试股票代码是否有效
Stock testStock = fetchSingleStockWithExponentialBackoff(code);
if (testStock == null) {
int retry = JOptionPane.showConfirmDialog(dialog,
"无法获取该股票数据,可能代码错误或网络问题。是否继续添加?",
"警告", JOptionPane.YES_NO_OPTION);
if (retry != JOptionPane.YES_OPTION) {
return;
}
}
// 添加到列表
stockCodes.add(code);
// 更新配置文件
saveStockCodesToFile();
// 刷新表格
fetchAndUpdateStockData();
dialog.dispose();
statusLabel.setText("已添加股票:" + code + ",配置文件已更新");
});
cancelButton.addActionListener(e -> dialog.dispose());
dialog.add(codeLabel);
dialog.add(codeField);
dialog.add(nameLabel);
dialog.add(nameField);
dialog.add(new JLabel()); // 占位
dialog.add(new JLabel()); // 占位
dialog.add(okButton);
dialog.add(cancelButton);
dialog.setVisible(true);
}
/**
* 保存股票代码到配置文件
*/
private void saveStockCodesToFile() {
try (BufferedWriter writer = new BufferedWriter(new FileWriter(CONFIG_FILE))) {
writer.write("# 自选股配置文件\n");
writer.write("# 每行一个股票代码(sh=沪市,sz=深市)\n");
for (String code : stockCodes) {
// 尝试获取股票名称用于注释
String name = "";
for (Stock stock : currentStocks) {
if (stock.getCode().equals(code)) {
name = stock.getName();
break;
}
}
if (!name.isEmpty()) {
writer.write(code + " # " + name + "\n");
} else {
writer.write(code + "\n");
}
}
} catch (IOException e) {
System.err.println("保存股票代码失败:" + e.getMessage());
JOptionPane.showMessageDialog(frame, "保存配置文件失败:" + e.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
}
}
/**
* 调整字号
*/
private void adjustFontSize(boolean isIncrease) {
int newSize = isIncrease ? currentFontSize + FONT_STEP : currentFontSize - FONT_STEP;
newSize = Math.max(MIN_FONT_SIZE, Math.min(newSize, MAX_FONT_SIZE));
if (newSize == currentFontSize) {
String tip = isIncrease ? "已达到最大字号(16号)" : "已达到最小字号(8号)";
JOptionPane.showMessageDialog(frame, tip, "提示", JOptionPane.INFORMATION_MESSAGE);
return;
}
currentFontSize = newSize;
updateTableFont();
statusLabel.setText(String.format("字号已调整为:%d号. 涨跌幅阈值:%s%% (Alt+F6 隐藏/显示)",
currentFontSize, changeThreshold));
}
/**
* 更新表格字体
*/
private void updateTableFont() {
Font newFont = new Font("宋体", Font.PLAIN, currentFontSize);
TableCellRenderer headerRenderer = stockTable.getTableHeader().getDefaultRenderer();
if (headerRenderer instanceof JLabel) {
((JLabel) headerRenderer).setFont(newFont);
}
stockTable.getTableHeader().setFont(newFont);
stockTable.setFont(newFont);
int newRowHeight = currentFontSize + 10;
stockTable.setRowHeight(newRowHeight);
stockTable.repaint();
stockTable.getTableHeader().repaint();
}
/**
* 加载配置文件
*/
private void loadSettings() {
File settingsFile = new File(SETTINGS_FILE);
Properties props = new Properties();
try {
if (settingsFile.exists()) {
props.load(new FileInputStream(settingsFile));
changeThreshold = Double.parseDouble(props.getProperty("change.threshold", "5.0"));
String highColor = props.getProperty("high.change.color", "#FF0000");
String normalColor = props.getProperty("normal.change.color", "#000000");
highChangeColor = Color.decode(highColor);
normalChangeColor = Color.decode(normalColor);
String savedFontSize = props.getProperty("font.size", "12");
currentFontSize = Integer.parseInt(savedFontSize);
currentFontSize = Math.max(MIN_FONT_SIZE, Math.min(currentFontSize, MAX_FONT_SIZE));
} else {
props.setProperty("change.threshold", "5.0");
props.setProperty("high.change.color", "#FF0000");
props.setProperty("normal.change.color", "#000000");
props.setProperty("font.size", "12");
props.store(new FileOutputStream(settingsFile), "Stock Monitor Settings");
}
} catch (Exception e) {
System.err.println("加载配置失败,使用默认值:" + e.getMessage());
changeThreshold = 5.0;
currentFontSize = 12;
}
}
/**
* 保存配置
*/
private void saveSettings() {
Properties props = new Properties();
props.setProperty("change.threshold", String.valueOf(changeThreshold));
props.setProperty("high.change.color", String.format("#%02X%02X%02X",
highChangeColor.getRed(), highChangeColor.getGreen(), highChangeColor.getBlue()));
props.setProperty("normal.change.color", String.format("#%02X%02X%02X",
normalChangeColor.getRed(), normalChangeColor.getGreen(), normalChangeColor.getBlue()));
props.setProperty("font.size", String.valueOf(currentFontSize));
try {
props.store(new FileOutputStream(SETTINGS_FILE), "Stock Monitor Settings");
} catch (IOException e) {
System.err.println("保存配置失败:" + e.getMessage());
}
}
/**
* 阈值设置对话框
*/
private void showSettingsDialog() {
JDialog dialog = new JDialog(frame, "阈值设置", true);
dialog.setSize(300, 220);
dialog.setLocationRelativeTo(frame);
dialog.setLayout(new GridLayout(5, 2, 10, 5));
dialog.setResizable(false);
JTextField thresholdField = new JTextField(String.valueOf(changeThreshold));
JButton highColorButton = new JButton("高涨幅颜色");
JButton normalColorButton = new JButton("正常颜色");
JLabel fontSizeLabel = new JLabel("当前字号:");
JLabel fontSizeValue = new JLabel(currentFontSize + "号(可通过主界面按钮调整)");
highColorButton.setBackground(highChangeColor);
normalColorButton.setBackground(normalChangeColor);
highColorButton.addActionListener(e -> {
Color selected = JColorChooser.showDialog(dialog, "选择高涨幅颜色", highChangeColor);
if (selected != null) {
highChangeColor = selected;
highColorButton.setBackground(selected);
}
});
normalColorButton.addActionListener(e -> {
Color selected = JColorChooser.showDialog(dialog, "选择正常颜色", normalChangeColor);
if (selected != null) {
normalChangeColor = selected;
normalColorButton.setBackground(selected);
}
});
JButton okButton = new JButton("确认");
okButton.addActionListener(e -> {
try {
changeThreshold = Double.parseDouble(thresholdField.getText());
saveSettings();
statusLabel.setText(String.format("配置已更新. 涨跌幅阈值:%s%%,当前字号:%d号 (Alt+F6 隐藏/显示)",
changeThreshold, currentFontSize));
stockTable.repaint();
dialog.dispose();
} catch (NumberFormatException ex) {
JOptionPane.showMessageDialog(dialog, "请输入有效的数字!", "错误", JOptionPane.ERROR_MESSAGE);
}
});
dialog.add(new JLabel("涨跌幅阈值(%):"));
dialog.add(thresholdField);
dialog.add(new JLabel("高涨幅颜色:"));
dialog.add(highColorButton);
dialog.add(new JLabel("正常颜色:"));
dialog.add(normalColorButton);
dialog.add(fontSizeLabel);
dialog.add(fontSizeValue);
dialog.add(new JLabel(""));
dialog.add(okButton);
dialog.setVisible(true);
}
/**
* 初始化股票代码
*/
private void initializeStockCodes() {
if (loadStockCodesFromFile()) {
statusLabel.setText(String.format("成功加载 %d 只股票代码. 涨跌幅阈值:%s%%,当前字号:%d号",
stockCodes.size(), changeThreshold, currentFontSize));
} else {
loadDefaultStockCodes();
statusLabel.setText(String.format("配置文件不存在,使用默认股票代码. 涨跌幅阈值:%s%%,当前字号:%d号",
changeThreshold, currentFontSize));
}
}
/**
* 从配置文件读取股票代码
*/
private boolean loadStockCodesFromFile() {
File configFile = new File(CONFIG_FILE);
if (!configFile.exists()) {
createDefaultConfigFile();
return false;
}
try (BufferedReader reader = new BufferedReader(new FileReader(configFile))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (!line.isEmpty() && !line.startsWith("#")) {
// 提取代码部分(忽略注释)
String code = line.split("#")[0].trim();
stockCodes.add(code);
}
}
return !stockCodes.isEmpty();
} catch (IOException e) {
System.err.println("读取配置文件失败:" + e.getMessage());
return false;
}
}
/**
* 创建默认配置文件
*/
private void createDefaultConfigFile() {
try (BufferedWriter writer = new BufferedWriter(new FileWriter(CONFIG_FILE))) {
writer.write("# 自选股配置文件\n");
writer.write("# 每行一个股票代码(sh=沪市,sz=深市)\n");
writer.write("sh600519 # 贵州茅台\n");
writer.write("sz000858 # 五粮液\n");
writer.write("sz300750 # 宁德时代\n");
writer.write("sz000001 # 平安银行\n");
writer.write("sh601318 # 中国平安\n");
writer.write("sh600036 # 招商银行\n");
writer.write("sz002594 # 比亚迪\n");
writer.write("sh601899 # 紫金矿业\n");
writer.write("sh600030 # 中信证券\n");
writer.write("sz000725 # 京东方A\n");
writer.write("sz301552 # 新莱福\n");
} catch (IOException e) {
System.err.println("创建默认配置文件失败:" + e.getMessage());
}
}
/**
* 加载默认股票代码
*/
private void loadDefaultStockCodes() {
stockCodes.add("sh600519");
stockCodes.add("sz000858");
stockCodes.add("sz300750");
stockCodes.add("sz000001");
stockCodes.add("sh601318");
stockCodes.add("sh600036");
stockCodes.add("sz002594");
stockCodes.add("sh601899");
stockCodes.add("sh600030");
stockCodes.add("sz000725");
stockCodes.add("sz301552");
}
/**
* 按涨跌幅排序
*/
private void sortStocksByChange() {
if (currentStocks.isEmpty()) {
JOptionPane.showMessageDialog(frame, "暂无股票数据可排序!", "提示", JOptionPane.INFORMATION_MESSAGE);
return;
}
sortAscending = !sortAscending;
if (sortAscending) {
Collections.sort(currentStocks, Comparator.comparingDouble(Stock::getChange));
statusLabel.setText(String.format("已按涨跌幅升序排序. 涨跌幅阈值:%s%%,当前字号:%d号",
changeThreshold, currentFontSize));
} else {
Collections.sort(currentStocks, (s1, s2) -> Double.compare(s2.getChange(), s1.getChange()));
statusLabel.setText(String.format("已按涨跌幅降序排序. 涨跌幅阈值:%s%%,当前字号:%d号",
changeThreshold, currentFontSize));
}
updateTableWithStocks(currentStocks);
JButton sortButton = (JButton) ((JPanel) ((JPanel) frame.getContentPane()).getComponent(0)).getComponent(1);
sortButton.setText(sortAscending ? "按涨跌幅升序排序" : "按涨跌幅降序排序");
}
/**
* 自定义表格渲染器
*/
private void customizeTableRenderer() {
updateTableFont();
// 共享的颜色渲染器(用于涨跌幅和涨跌百分位)
DefaultTableCellRenderer sharedRenderer = new DefaultTableCellRenderer() {
@Override
public Component getTableCellRendererComponent(JTable table, Object value,
boolean isSelected, boolean hasFocus, int row, int column) {
Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if (row < currentStocks.size()) {
double percentValue = 0.0;
try {
// 根据列类型获取对应的值
if (column == 2) { // 涨跌幅列
Stock stock = currentStocks.get(row);
percentValue = stock.getChange();
} else if (column == 4 || column == 5) { // 涨跌百分位列
String valueStr = value.toString().replace("%", "").trim();
percentValue = Double.parseDouble(valueStr);
}
// 统一的颜色显示逻辑
double absPercent = Math.abs(percentValue);
if (absPercent > changeThreshold) {
c.setForeground(highChangeColor);
} else {
c.setForeground(normalChangeColor);
}
// 正值加粗显示
if (percentValue > 0) {
c.setFont(c.getFont().deriveFont(Font.BOLD));
} else {
c.setFont(c.getFont().deriveFont(Font.PLAIN));
}
} catch (Exception e) {
c.setForeground(normalChangeColor);
c.setFont(c.getFont().deriveFont(Font.PLAIN));
}
}
// 选中状态处理
if (isSelected) {
c.setBackground(table.getSelectionBackground());
c.setForeground(table.getSelectionForeground());
} else {
c.setBackground(table.getBackground());
}
return c;
}
};
sharedRenderer.setHorizontalAlignment(SwingConstants.RIGHT);
// 成交额渲染器
DefaultTableCellRenderer amountRenderer = new DefaultTableCellRenderer() {
@Override
public Component getTableCellRendererComponent(JTable table, Object value,
boolean isSelected, boolean hasFocus, int row, int column) {
Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if (isSelected) {
c.setBackground(table.getSelectionBackground());
} else {
c.setBackground(table.getBackground());
}
// 大额成交额特殊显示
try {
String valueStr = value.toString().replace(",", "").replace("万元", "").trim();
double amount = Double.parseDouble(valueStr);
if (amount > 20000) { // 超过2亿元
c.setFont(c.getFont().deriveFont(Font.BOLD));
c.setForeground(new Color(0, 10, 0));
} else {
c.setFont(c.getFont().deriveFont(Font.PLAIN));
c.setForeground(normalChangeColor);
}
} catch (Exception e) {
// 保持默认样式
}
return c;
}
};
amountRenderer.setHorizontalAlignment(SwingConstants.RIGHT);
DefaultTableCellRenderer rightRenderer = new DefaultTableCellRenderer();
rightRenderer.setHorizontalAlignment(SwingConstants.RIGHT);
DefaultTableCellRenderer leftRenderer = new DefaultTableCellRenderer();
leftRenderer.setHorizontalAlignment(SwingConstants.LEFT);
// 设置渲染器
stockTable.getColumnModel().getColumn(0).setCellRenderer(leftRenderer);
stockTable.getColumnModel().getColumn(1).setCellRenderer(rightRenderer);
stockTable.getColumnModel().getColumn(2).setCellRenderer(sharedRenderer); // 涨跌幅
stockTable.getColumnModel().getColumn(3).setCellRenderer(amountRenderer); // 成交额
stockTable.getColumnModel().getColumn(4).setCellRenderer(sharedRenderer); // 涨幅百分位
stockTable.getColumnModel().getColumn(5).setCellRenderer(sharedRenderer); // 跌幅百分位
stockTable.getColumnModel().getColumn(6).setCellRenderer(rightRenderer);
// 调整列宽适配新内容
stockTable.getColumnModel().getColumn(0).setPreferredWidth(150);
stockTable.getColumnModel().getColumn(1).setPreferredWidth(80);
stockTable.getColumnModel().getColumn(2).setPreferredWidth(90);
stockTable.getColumnModel().getColumn(3).setPreferredWidth(120); // 成交额列加宽
stockTable.getColumnModel().getColumn(4).setPreferredWidth(110);
stockTable.getColumnModel().getColumn(5).setPreferredWidth(110);
stockTable.getColumnModel().getColumn(6).setPreferredWidth(80);
}
/**
* 启动自动刷新
*/
private void startAutoRefresh() {
Timer timer = new Timer("StockRefreshTimer", true);
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
if (frame.isVisible()) {
SwingUtilities.invokeLater(() -> statusLabel.setText(String.format("自动刷新中...涨跌幅阈值:%s%%,当前字号:%d号",
changeThreshold, currentFontSize)));
fetchAndUpdateStockData();
}
}
}, 30000, 30000);
}
/**
* 获取并更新股票数据
*/
private void fetchAndUpdateStockData() {
new Thread(() -> {
java.util.List<Stock> stocks = new java.util.ArrayList<>();
AtomicInteger failedCount = new AtomicInteger(0);
for (String code : stockCodes) {
Stock stock = fetchSingleStockWithExponentialBackoff(code);
if (stock != null) {
stocks.add(stock);
} else {
failedCount.incrementAndGet();
}
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
currentStocks = new java.util.ArrayList<>(stocks);
SwingUtilities.invokeLater(() -> {
updateTableWithStocks(stocks);
if (failedCount.get() > 0) {
statusLabel.setText(String.format("刷新完成!%d 只股票加载失败. 涨跌幅阈值:%.1f%%,当前字号:%d号",
failedCount.get(), changeThreshold, currentFontSize));
} else {
statusLabel.setText(String.format("刷新成功!下次刷新:30秒后. 涨跌幅阈值:%.1f%%,当前字号:%d号",
changeThreshold, currentFontSize));
}
});
}, "StockDataFetcher").start();
}
/**
* 更新表格数据(展示成交额)
*/
private void updateTableWithStocks(java.util.List<Stock> stocks) {
tableModel.setRowCount(0);
for (Stock stock : stocks) {
// 计算涨跌百分位(相对于昨收价)
double highPercent = ((stock.getHigh() - stock.getClose()) / stock.getClose()) * 100;
double lowPercent = ((stock.getLow() - stock.getClose()) / stock.getClose()) * 100;
Object[] rowData = {
stock.getCode() + " / " + stock.getName(),
PRICE_FORMAT.format(stock.getPrice()),
CHANGE_FORMAT.format(stock.getChange()) + "%",
AMOUNT_FORMAT.format(stock.getAmount()/10000) , // 成交额(万元)
PERCENT_FORMAT.format(highPercent) + "%", // 涨幅百分位
PERCENT_FORMAT.format(lowPercent) + "%", // 跌幅百分位
PRICE_FORMAT.format(stock.getClose())
};
tableModel.addRow(rowData);
}
}
/**
* 初始化系统托盘
*/
private void setupSystemTray() {
if (!SystemTray.isSupported()) {
JOptionPane.showMessageDialog(frame, "您的系统不支持系统托盘功能");
return;
}
systemTray = SystemTray.getSystemTray();
Image image = createSimpleIcon();
trayIcon = new TrayIcon(image, "自选股监控");
trayIcon.setImageAutoSize(true);
PopupMenu popupMenu = new PopupMenu();
MenuItem showHideItem = new MenuItem("显示/隐藏窗口");
MenuItem settingsItem = new MenuItem("阈值设置");
MenuItem exitItem = new MenuItem("退出");
showHideItem.addActionListener(e -> toggleWindowVisibility());
settingsItem.addActionListener(e -> SwingUtilities.invokeLater(this::showSettingsDialog));
exitItem.addActionListener(e -> {
systemTray.remove(trayIcon);
System.exit(0);
});
popupMenu.add(showHideItem);
popupMenu.add(settingsItem);
popupMenu.addSeparator();
popupMenu.add(exitItem);
trayIcon.setPopupMenu(popupMenu);
trayIcon.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
toggleWindowVisibility();
}
}
});
try {
systemTray.add(trayIcon);
} catch (AWTException e) {
JOptionPane.showMessageDialog(frame, "无法添加系统托盘图标: " + e.getMessage());
}
}
/**
* 创建托盘图标
*/
private Image createSimpleIcon() {
int size = 16;
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
g2d.setColor(new Color(0, 102, 204, 255));
g2d.fillRect(0, 0, size, size);
g2d.setColor(Color.WHITE);
g2d.setFont(new Font("Arial", Font.BOLD, 12));
g2d.drawString("股", 2, 13);
g2d.dispose();
return image;
}
/**
* 设置全局快捷键
*/
private void setupGlobalHotkey() {
Toolkit.getDefaultToolkit().addAWTEventListener(new AWTEventListener() {
private boolean altPressed = false;
@Override
public void eventDispatched(AWTEvent event) {
if (event instanceof KeyEvent) {
KeyEvent keyEvent = (KeyEvent) event;
if (keyEvent.getID() == KeyEvent.KEY_PRESSED) {
if (keyEvent.getKeyCode() == KeyEvent.VK_ALT) {
altPressed = true;
} else if (altPressed && keyEvent.getKeyCode() == KeyEvent.VK_F6) {
toggleWindowVisibility();
keyEvent.consume();
}
} else if (keyEvent.getID() == KeyEvent.KEY_RELEASED) {
if (keyEvent.getKeyCode() == KeyEvent.VK_ALT) {
altPressed = false;
}
}
}
}
}, AWTEvent.KEY_EVENT_MASK);
}
/**
* 切换窗口显示/隐藏
*/
private void toggleWindowVisibility() {
SwingUtilities.invokeLater(() -> {
boolean isVisible = frame.isVisible();
frame.setVisible(!isVisible);
if (!isVisible) {
frame.toFront();
frame.requestFocus();
statusLabel.setText(String.format("窗口已显示. Alt+F6隐藏. 涨跌幅阈值:%.1f%%,当前字号:%d号",
changeThreshold, currentFontSize));
trayIcon.displayMessage("自选股监控", "窗口已显示", TrayIcon.MessageType.INFO);
} else {
statusLabel.setText("窗口已隐藏. Alt+F6显示.");
trayIcon.displayMessage("自选股监控", "窗口已隐藏", TrayIcon.MessageType.INFO);
}
});
}
/**
* 单股票获取(解析成交额)
*/
private Stock fetchSingleStockWithExponentialBackoff(String code) {
for (int retry = 0; retry < MAX_RETRIES; retry++) {
HttpURLConnection conn = null;
try {
long retryDelay = INITIAL_RETRY_DELAY * (1 << retry);
if (retry > 0) {
Thread.sleep(retryDelay);
}
String encodedCode = URLEncoder.encode(code, "UTF-8");
URL url = new URL("https://hq.sinajs.cn/list=" + encodedCode);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("User-Agent", USER_AGENT);
conn.setRequestProperty("Referer", REFERER);
conn.setRequestProperty("Accept", ACCEPT);
conn.setRequestProperty("Accept-Encoding", ACCEPT_ENCODING);
conn.setRequestProperty("Accept-Language", ACCEPT_LANGUAGE);
conn.setConnectTimeout(BASE_CONNECT_TIMEOUT + (retry * 5000));
conn.setReadTimeout(BASE_READ_TIMEOUT + (retry * 5000));
if (conn.getResponseCode() != 200) {
continue;
}
String responseData = readResponse(conn);
if (responseData == null || !responseData.contains("hq_str_")) {
continue;
}
Stock stock = parseStockData(responseData, code);
if (stock != null) {
return stock;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
System.err.println("获取股票数据失败(" + code + "):" + e.getMessage());
continue;
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
return null;
}
/**
* 读取响应数据
*/
private String readResponse(HttpURLConnection conn) throws IOException {
String contentEncoding = conn.getContentEncoding();
InputStream in = conn.getInputStream();
if (contentEncoding != null && contentEncoding.contains("gzip")) {
in = new GZIPInputStream(in);
}
String[] encodings = {"GBK", "GB18030", "UTF-8"};
for (String encoding : encodings) {
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(in, encoding));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
reader.close();
in.close();
return sb.toString().trim();
} catch (IOException e) {
continue;
}
}
return null;
}
/**
* 解析股票数据(提取成交额)
*/
private Stock parseStockData(String data, String code) {
try {
String[] parts = data.split("=");
if (parts.length < 2) {
System.err.println("解析失败(" + code + "):数据格式错误");
return null;
}
String quotePart = parts[1].replaceAll("\"|;", "").trim();
String[] stockFields = quotePart.split(",");
if (stockFields.length < 10) {
System.err.println("解析失败(" + code + "):核心字段不足(实际" + stockFields.length + "个)");
return null;
}
String name = stockFields[0].trim().isEmpty() ? "未知名称" : stockFields[0].trim();
double close = parseDoubleSafely(stockFields[2], 0.0);
double price = parseDoubleSafely(stockFields[3], 0.0);
double high = parseDoubleSafely(stockFields[4], 0.0);
double low = parseDoubleSafely(stockFields[5], 0.0);
double volume = parseDoubleSafely(stockFields[8], 0.0);
double amount = parseDoubleSafely(stockFields[9], 0.0); // 成交额(万元)
double change = (close == 0) ? 0.0 : ((price - close) / close) * 100;
if (price <= 0) {
System.err.println("解析失败(" + code + "):最新价无效(" + stockFields[3] + ")");
return null;
}
return new Stock(code, name, price, change, volume, amount, 0.0, high, low, close);
} catch (Exception e) {
System.err.println("解析股票数据失败(" + code + "):" + e.getMessage());
return null;
}
}
/**
* 安全解析double
*/
private double parseDoubleSafely(String str, double defaultValue) {
if (str == null || str.trim().isEmpty()) {
return defaultValue;
}
try {
return Double.parseDouble(str.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
/**
* 股票数据模型(包含成交额)
*/
public static class Stock {
private final String code;
private final String name;
private final double price;
private final double change;
private final double volume;
private final double amount; // 成交额(万元)
private final double open;
private final double high;
private final double low;
private final double close;
public Stock(String code, String name, double price, double change, double volume,
double amount, double open, double high, double low, double close) {
this.code = code;
this.name = name;
this.price = price;
this.change = change;
this.volume = volume;
this.amount = amount;
this.open = open;
this.high = high;
this.low = low;
this.close = close;
}
public String getCode() { return code; }
public String getName() { return name; }
public double getPrice() { return price; }
public double getChange() { return change; }
public double getVolume() { return volume; }
public double getAmount() { return amount; } // 成交额getter
public double getHigh() { return high; }
public double getLow() { return low; }
public double getClose() { return close; }
}
/**
* 主方法
*/
public static void main(String[] args) {
SwingUtilities.invokeLater(StockMonitorApp::new);
}
} |
|