diff --git a/.gitignore b/.gitignore index c43bfc5..e669e96 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ android/.idea/caches ios/build ios/Pods android/app/release +__pycache__ diff --git a/requirements-gui.txt b/requirements-gui.txt new file mode 100644 index 0000000..5b97c7c --- /dev/null +++ b/requirements-gui.txt @@ -0,0 +1 @@ +PySide6>=6.6.0 diff --git a/servers/walletman b/servers/walletman index 7ee1c24..f551940 160000 --- a/servers/walletman +++ b/servers/walletman @@ -1 +1 @@ -Subproject commit 7ee1c24dd23207f6501709d0e2f572f18cfac22d +Subproject commit f5519400a0c970cd984c138e059cbc8b3ade92b8 diff --git a/upi.html b/upi.html index a0351df..ce706c9 100644 --- a/upi.html +++ b/upi.html @@ -140,6 +140,10 @@ background: linear-gradient(135deg, #00897b, #4db6ac); } + .c-gpay { + background: linear-gradient(135deg, #1a73e8, #34a853); + } + .c-upi { background: linear-gradient(135deg, #37474f, #78909c); } @@ -192,6 +196,7 @@ + @@ -253,7 +258,7 @@ bharatpe_biz: { label: 'BharatPe Business', pa: 'BHARATPE.8A0T1R2O5E77327@fbpe', pn: 'BharatPe Merchant', - extra: () => '&tn=Pay%20To%20BharatPe%20Merchant', + extra: () => '&tn=Pay+To+BharatPe+Merchant', warn: null, }, paytm_per: { @@ -276,7 +281,7 @@ }, freecharge_per: { label: 'Freecharge Personal', - pa: 'simple6812@freecharge', pn: 'Gurvir Singh', + pa: '9124307439@freecharge', pn: 'Niranjan Mishra', extra: () => '', warn: null, }, @@ -310,6 +315,7 @@ paytm: (pa, pn, am, extra) => `paytmmp://cash_wallet?featuretype=money_transfer&pa=${pa}&pn=${pn}&am=${am}&cu=INR&mc=0000&mode=02&purpose=00&orgid=159002${extra}`, phonepe: (pa, pn, am) => phonepeNative(pa, pn, am, 'payment'), phonepe_pay: (pa, pn, am, extra) => `phonepe://pay?pa=${pa}&pn=${pn}&am=${am}&cu=INR${extra}`, + gpay: (pa, pn, am, extra) => `tez://upi/pay?pa=${pa}&pn=${pn}&am=${am}&cu=INR${extra}`, mobikwik: (pa, pn, am, extra) => { const tn = (extra.match(/[?&]tn=([^&]*)/) || [])[1] || 'payment'; return `mobikwik://moneytransfer/upi/verifyVpa?vpa=${encodeURIComponent(pa)}&amount=${am}¬e=${tn}`; }, freecharge: (pa, pn, am, extra) => `freechargeupi://pay?pa=${pa}&pn=${pn}&am=${am}&cu=INR${extra}`, // fallback 在 scheduleAutoClick 里处理 @@ -318,7 +324,7 @@ const APP_COLORS = { paytm: 'c-paytm', phonepe: 'c-phonepe', phonepe_pay: 'c-phonepe', - mobikwik: 'c-mobikwik', freecharge: 'c-freecharge', upi: 'c-upi', + gpay: 'c-gpay', mobikwik: 'c-mobikwik', freecharge: 'c-freecharge', upi: 'c-upi', }; const PHONEPE_WALLETS = new Set(['phonepe_biz', 'phonepe_per']); diff --git a/walletsgui.py b/walletsgui.py new file mode 100644 index 0000000..30407a4 --- /dev/null +++ b/walletsgui.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""wallets.json 图形管理(PySide6)。默认同目录下 servers/walletman/cmd/wallets.json。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QCloseEvent, QKeySequence +from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QTextEdit, + QVBoxLayout, + QWidget, +) + +# 与 servers/walletman/cmd/tools/main.go 中 walletTypeMenu 一致 +WALLET_TYPES = [ + "paytm", + "phonepe", + "mobikwik", + "freecharge", + "bharatpe business", + "paytm business", + "phonepe business", + "googlepay business", +] + + +def default_wallets_path() -> Path: + root = Path(__file__).resolve().parent + for rel in ( + root / "servers" / "walletman" / "cmd" / "wallets.json", + root / "wallets.json", + ): + if rel.is_file(): + return rel + return root / "servers" / "walletman" / "cmd" / "wallets.json" + + +def parse_params(s: str) -> dict[str, Any]: + s = s.strip() + if not s: + raise ValueError("params 为空") + return json.loads(s) + + +def format_params_pretty(s: str) -> str: + s = s.strip() + if not s: + return "" + return json.dumps(json.loads(s), ensure_ascii=False, indent=2) + + +def compact_params_json(s: str) -> str: + return json.dumps(json.loads(s), ensure_ascii=False, separators=(",", ":")) + + +def suggest_wallet_id(wallet_type: str, params: str) -> str: + try: + p = json.loads(params) + except json.JSONDecodeError: + return "" + mob = p.get("mobile") + if isinstance(mob, str) and mob: + return f"{wallet_type}_{mob}" + return "" + + +class WalletEditDialog(QDialog): + def __init__( + self, + parent: QWidget | None, + title: str, + wallet_type: str, + wallet_id: str, + params: str, + allow_change_id: bool = True, + *, + is_add: bool = False, + ) -> None: + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumSize(640, 480) + self._is_add = is_add + self._id_locked = not allow_change_id and bool(wallet_id) + + layout = QVBoxLayout(self) + form = QFormLayout() + self.edit_uid: QLineEdit | None = None + if is_add: + self.edit_uid = QLineEdit("10000") + self.edit_uid.setPlaceholderText("如 10000") + form.addRow("用户", self.edit_uid) + self.combo_type = QComboBox() + self.combo_type.setEditable(True) + for t in WALLET_TYPES: + self.combo_type.addItem(t) + idx = self.combo_type.findText(wallet_type, Qt.MatchFlag.MatchFixedString) + if idx >= 0: + self.combo_type.setCurrentIndex(idx) + else: + self.combo_type.setCurrentText(wallet_type) + self.edit_id = QLineEdit(wallet_id) + self.edit_id.setPlaceholderText("留空则根据 params 里 mobile 自动生成") + if self._id_locked: + self.edit_id.setReadOnly(True) + form.addRow("类型", self.combo_type) + form.addRow("钱包 key", self.edit_id) + layout.addLayout(form) + self.text_params = QTextEdit() + self.text_params.setFontFamily("Menlo" if sys.platform == "darwin" else "Consolas") + self.text_params.setPlainText(params) + layout.addWidget(QLabel("params (JSON)")) + layout.addWidget(self.text_params, 1) + box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + box.accepted.connect(self._accept) + box.rejected.connect(self.reject) + layout.addWidget(box) + + def _accept(self) -> None: + ptxt = self.text_params.toPlainText().strip() + try: + parse_params(ptxt) + except (json.JSONDecodeError, ValueError) as e: + QMessageBox.warning(self, "JSON 无效", str(e)) + return + self._ok = True + self.accept() + + @property + def user_id(self) -> str: + if not self._is_add or self.edit_uid is None: + return "10000" + return self.edit_uid.text().strip() or "10000" + return self.combo_type.currentText().strip() + + @property + def wallet_id(self) -> str: + return self.edit_id.text().strip() + + @property + def params_compact(self) -> str: + return compact_params_json(self.text_params.toPlainText()) + + +class WalletsWindow(QMainWindow): + COL_USER, COL_ID, COL_TYPE, COL_PREVIEW = 0, 1, 2, 3 + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Wallets 管理器") + self.resize(1100, 600) + self._path: Path = default_wallets_path() + self._data: dict[str, dict[str, dict[str, str]]] = {} + self._dirty = False + + central = QWidget() + self.setCentralWidget(central) + v = QVBoxLayout(central) + path_row = QHBoxLayout() + self.label_path = QLabel() + path_row.addWidget(self.label_path) + btn_browse = QPushButton("打开…") + btn_browse.clicked.connect(self._open_file) + path_row.addWidget(btn_browse) + v.addLayout(path_row) + + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["用户", "Wallet ID", "类型", "params 摘要"]) + self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.table.doubleClicked.connect(self._on_double_click) + v.addWidget(self.table) + + row = QHBoxLayout() + for text, fn in [ + ("重新加载", self._reload), + ("保存", self._save), + ("添加", self._add), + ("编辑", self._edit), + ("删除", self._delete), + ]: + b = QPushButton(text) + b.clicked.connect(fn) + row.addWidget(b) + v.addLayout(row) + self._update_path_label() + self._build_menu() + self.statusBar().showMessage("就绪") + self._load_file(self._path, quiet=True) + + def _build_menu(self) -> None: + m = self.menuBar().addMenu("文件") + a_open = QAction("打开…", self) + a_open.setShortcut(QKeySequence.StandardKey.Open) + a_open.triggered.connect(self._open_file) + m.addAction(a_open) + a_save = QAction("保存", self) + a_save.setShortcut(QKeySequence.StandardKey.Save) + a_save.triggered.connect(self._save) + m.addAction(a_save) + m.addSeparator() + a_exit = QAction("退出", self) + a_exit.setShortcut(QKeySequence.StandardKey.Quit) + a_exit.triggered.connect(self.close) + m.addAction(a_exit) + + def _update_path_label(self) -> None: + self.label_path.setText(f"文件: {self._path} " + ("● 未保存" if self._dirty else "")) + self.label_path.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + + def _set_dirty(self, d: bool) -> None: + self._dirty = d + self._update_path_label() + + def _load_file(self, path: Path, quiet: bool = False) -> None: + if not path.is_file(): + self._data = {} + self._rebuild_table() + self._set_dirty(False) + if not quiet: + QMessageBox.information(self, "提示", f"文件不存在,将使用空数据:\n{path}") + return + try: + raw = path.read_text(encoding="utf-8") + self._data = json.loads(raw) + except (OSError, json.JSONDecodeError) as e: + QMessageBox.critical(self, "读取失败", str(e)) + return + self._path = path.resolve() + self._rebuild_table() + self._set_dirty(False) + self.setWindowTitle(f"Wallets 管理器 — {self._path.name}") + + def _reload(self) -> None: + if self._dirty: + r = QMessageBox.question( + self, "未保存", "是否丢弃未保存的修改?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if r != QMessageBox.StandardButton.Yes: + return + self._load_file(self._path, quiet=True) + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + try: + text = json.dumps(self._data, ensure_ascii=False, indent=2) + "\n" + self._path.write_text(text, encoding="utf-8") + except OSError as e: + QMessageBox.critical(self, "保存失败", str(e)) + return + self._set_dirty(False) + QMessageBox.information(self, "已保存", str(self._path)) + + def _open_file(self) -> None: + if self._dirty: + r = QMessageBox.question( + self, "未保存", "是否先保存?", + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + ) + if r == QMessageBox.StandardButton.Save: + self._save() + elif r == QMessageBox.StandardButton.Cancel: + return + path, _ = QFileDialog.getOpenFileName( + self, "选择 wallets.json", str(self._path.parent), "JSON (*.json);;*.*" + ) + if path: + self._load_file(Path(path)) + + def _rebuild_table(self) -> None: + rows: list[tuple[str, str, str, str]] = [] + for uid, wallets in self._data.items(): + for wid, ent in wallets.items(): + wt = ent.get("walletType", "") + p = ent.get("params", "") + prev = p[:80] + ("…" if len(p) > 80 else "") + rows.append((str(uid), wid, str(wt), prev)) + rows.sort(key=lambda x: (x[0], x[1])) + self.table.setRowCount(len(rows)) + for i, (uid, wid, wt, prev) in enumerate(rows): + self.table.setItem(i, self.COL_USER, QTableWidgetItem(uid)) + self.table.setItem(i, self.COL_ID, QTableWidgetItem(wid)) + self.table.setItem(i, self.COL_TYPE, QTableWidgetItem(wt)) + self.table.setItem(i, self.COL_PREVIEW, QTableWidgetItem(prev)) + + def _selected_cell(self) -> tuple[int, int] | None: + idx = self.table.currentIndex() + if not idx.isValid(): + return None + return idx.row(), idx.column() + + def _row_to_keys(self, row: int) -> tuple[str, str] | None: + u = self.table.item(row, self.COL_USER) + w = self.table.item(row, self.COL_ID) + if not u or not w: + return None + return u.text(), w.text() + + def _on_double_click(self) -> None: + self._edit() + + def _add(self) -> None: + default_type = WALLET_TYPES[0] + dlg = WalletEditDialog(self, "添加钱包", default_type, "", "{}", allow_change_id=True, is_add=True) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + uid = dlg.user_id + wt, pid, pcompact = dlg.wallet_type, dlg.wallet_id, dlg.params_compact + if not pid: + pid = suggest_wallet_id(wt, pcompact) + if not pid: + QMessageBox.warning(self, "错误", "无法生成 Wallet ID,请填写「钱包 key」或确保 params 含 mobile") + return + if uid not in self._data: + self._data[uid] = {} + existed = pid in self._data[uid] + self._data[uid][pid] = {"walletType": wt, "params": pcompact} + self._rebuild_table() + self._set_dirty(True) + if existed: + self.statusBar().showMessage(f"已更新(原记录已存在): {uid} / {pid}", 5000) + else: + self.statusBar().showMessage(f"已添加: {uid} / {pid}", 5000) + + def _edit(self) -> None: + sel = self._selected_cell() + if not sel: + QMessageBox.information(self, "提示", "先选中一行") + return + row = sel[0] + keys = self._row_to_keys(row) + if not keys: + return + uid, wid = keys + ent = self._data.get(uid, {}).get(wid) + if not ent: + return + wt = ent["walletType"] + try: + pretty = format_params_pretty(ent["params"]) + except (json.JSONDecodeError, TypeError): + pretty = str(ent.get("params", "")) + dlg = WalletEditDialog(self, "编辑钱包", wt, wid, pretty, allow_change_id=False, is_add=False) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + nwt, _, pcompact = dlg.wallet_type, dlg.wallet_id, dlg.params_compact + self._data[uid][wid] = {"walletType": nwt, "params": pcompact} + self._rebuild_table() + self._set_dirty(True) + + def _delete(self) -> None: + sel = self._selected_cell() + if not sel: + return + keys = self._row_to_keys(sel[0]) + if not keys: + return + uid, wid = keys + if ( + QMessageBox.question( + self, "删除", f"确定删除 {uid} / {wid} ?", + ) + != QMessageBox.StandardButton.Yes + ): + return + self._data.get(uid, {}).pop(wid, None) + if self._data.get(uid) == {}: + self._data.pop(uid, None) + self._rebuild_table() + self._set_dirty(True) + + def closeEvent(self, event: QCloseEvent) -> None: + if self._dirty: + r = QMessageBox.question( + self, "未保存", "是否保存后退出?", + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + ) + if r == QMessageBox.StandardButton.Save: + self._save() + elif r == QMessageBox.StandardButton.Cancel: + event.ignore() + return + event.accept() + + +def main() -> None: + app = QApplication(sys.argv) + w = WalletsWindow() + w.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main()