feat: 新增主机指派功能,完善主机管理与供应商管理能力
11 个文件变更
+2887
-169
13172193298@163.com
| •MPHX/plugin.php | +19 -1 |
| •app_plugins/zjmfmanager_reserve/bootstrap.php | +636 -18 |
| •app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php | +153 -1 |
| •app_plugins/zjmfmanager_reserve/lib/upstream.php | +1035 -85 |
| •app_plugins/zjmfmanager_reserve/lib/zjmf.php | +174 -18 |
| •app_plugins/zjmfmanager_reserve/views/admin/assign.php | +295 -0 |
| •app_plugins/zjmfmanager_reserve/views/admin/hosts.php | +44 -9 |
| •app_plugins/zjmfmanager_reserve/views/admin/suppliers.php | +40 -1 |
| •app_plugins/zjmfmanager_reserve/views/host.php | +483 -34 |
| •app_plugins/zjmfmanager_reserve/views/hosts.php | +1 -1 |
| •templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue | +7 -1 |
变更内容
diff --git a/MPHX/plugin.php b/MPHX/plugin.php
index 249acc3..463db0a 100644
--- a/MPHX/plugin.php
+++ b/MPHX/plugin.php
@@ -1365,7 +1365,25 @@ function mnbt_plugin_dispatch_route()
while (ob_get_level() > $bufferLevel) ob_end_clean();
error_log('[MNBT plugin] route ' . $item['method'] . ' ' . $item['path'] . ' @' . ($item['plugin'] ?? '?') . ': ' . $e->getMessage());
$GLOBALS['mnbt_plugin_current'] = $prev;
- continue;
+ // 路由已匹配但回调抛异常:渲染错误页终止请求,
+ // 不再静默 continue 落到默认行为(曾导致用户端被跳转到核心虚拟主机登录页)
+ if (!headers_sent()) {
+ http_response_code(500);
+ @header('Content-Type: text/html; charset=UTF-8');
+ }
+ echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>页面错误</title>'
+ . '<style>body{font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f2f3f5;color:#1a2e28;display:grid;place-items:center;min-height:100vh;margin:0}'
+ . '.box{max-width:680px;padding:32px 40px;background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.06)}'
+ . 'h1{font-size:18px;margin:0 0 10px;color:#d54941}'
+ . 'p{font-size:14px;line-height:1.7;color:#555;margin:8px 0}'
+ . 'code{background:#f2f3f5;padding:2px 6px;border-radius:4px;font-size:12px}'
+ . 'pre{background:#fafbfc;border:1px solid #eee;border-radius:8px;padding:14px;overflow:auto;font-size:12px;line-height:1.6;color:#333}</style></head>'
+ . '<body><div class="box"><h1>页面处理出错</h1>'
+ . '<p>路由 <code>' . htmlspecialchars($item['method'] . ' ' . $item['path'], ENT_QUOTES, 'UTF-8')
+ . '</code> 处理异常,请稍后重试或联系管理员。</p>'
+ . '<pre>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</pre>'
+ . '</div></body></html>';
+ exit;
}
$GLOBALS['mnbt_plugin_current'] = $prev;
if ($result === false) {
diff --git a/app_plugins/zjmfmanager_reserve/bootstrap.php b/app_plugins/zjmfmanager_reserve/bootstrap.php
index a0a7ab4..3401a92 100644
--- a/app_plugins/zjmfmanager_reserve/bootstrap.php
+++ b/app_plugins/zjmfmanager_reserve/bootstrap.php
@@ -88,6 +88,7 @@ mnbt_register_page('admin', 'suppliers', 'views/admin/suppliers.php', '供应商
mnbt_register_page('admin', 'products', 'views/admin/products.php', '商品管理');
mnbt_register_page('admin', 'orders', 'views/admin/orders.php', '订单管理');
mnbt_register_page('admin', 'hosts', 'views/admin/hosts.php', '主机管理');
+mnbt_register_page('admin', 'assign', 'views/admin/assign.php', '主机指派');
mnbt_register_page('admin', 'logs', 'views/admin/logs.php', '操作日志');
// 侧边栏菜单(三级结构)
@@ -100,6 +101,7 @@ mnbt_register_menu('admin', [
['title' => '商品管理', 'page' => 'products', 'icon' => 'mdi-package-variant', 'multitabs' => true],
['title' => '订单管理', 'page' => 'orders', 'icon' => 'mdi-receipt', 'multitabs' => true],
['title' => '主机管理', 'page' => 'hosts', 'icon' => 'mdi-server', 'multitabs' => true],
+ ['title' => '主机指派', 'page' => 'assign', 'icon' => 'mdi-gift', 'multitabs' => true],
['title' => '操作日志', 'page' => 'logs', 'icon' => 'mdi-clipboard-text', 'multitabs' => true],
],
]);
@@ -228,6 +230,23 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_test_supplier', function () {
json_exit_success($result['msg'] ?? '连接成功');
});
+// 查询供应商上游余额(GET /user_info → data.credit)
+mnbt_register_ajax('admin', 'p_zjmf_admin_supplier_balance', function () {
+ mnbt_plugin_require_admin();
+ $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
+ if (!$supplier) {
+ json_exit_error('供应商不存在');
+ }
+ $result = ZjmfUpstream::balance($supplier);
+ if (empty($result['ok'])) {
+ json_exit_error($result['msg'] ?? '获取失败');
+ }
+ json_exit_success('ok', [
+ 'credit' => (string)($result['credit'] ?? ''),
+ 'currency' => (string)($result['currency'] ?? ''),
+ ]);
+});
+
// 拉取供应商上游商品列表(供同步弹窗选择)
mnbt_register_ajax('admin', 'p_zjmf_admin_upstream_products', function () {
mnbt_plugin_require_admin();
@@ -264,6 +283,237 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_upstream_products', function () {
]);
});
+// 拉取当前账户(代理)已开通的主机列表(产品分配页用)
+// 来源:GET host/list,每台主机一行;标注本地已指派状态
+mnbt_register_ajax('admin', 'p_zjmf_admin_upstream_owned_products', function () {
+ mnbt_plugin_require_admin();
+ global $DB;
+ $supplier = zjmf_supplier_get((int)($_POST['id'] ?? 0));
+ if (!$supplier) {
+ json_exit_error('供应商不存在');
+ }
+
+ // 分页拉全量主机列表(limit 尽量大,翻页兜底;安全上限防死循环)
+ $items = [];
+ $sum = 0;
+ $maxPage = 1;
+ $page = 1;
+ $safety = 30;
+ while ($page <= $maxPage && $safety-- > 0) {
+ $res = ZjmfUpstream::hostList($supplier, ['page' => $page, 'limit' => 100]);
+ if (empty($res['ok'])) {
+ json_exit_error('拉取失败:' . (string)($res['msg'] ?? ''));
+ }
+ $data = $res['data'] ?? [];
+ foreach (($data['list'] ?? []) as $h) {
+ $items[] = $h;
+ }
+ $sum = (int)($data['sum'] ?? count($items));
+ $maxPage = (int)($data['max_page'] ?? 0);
+ if ($maxPage <= 0) {
+ $maxPage = 1;
+ }
+ if ($sum > 0 && count($items) >= $sum) {
+ break;
+ }
+ $page++;
+ }
+
+ // 本地已指派的主机集合(供应商 + 上游主机ID)
+ $assigned = [];
+ $rows = $DB->get_all_prepare(
+ "SELECT up_host_id FROM MN_plugin_zjmf_host
+ WHERE supplier_id=? AND up_host_id>0",
+ [(int)$supplier['id']]
+ ) ?: [];
+ foreach ($rows as $r) {
+ $assigned[(int)$r['up_host_id']] = true;
+ }
+
+ $list = [];
+ foreach ($items as $h) {
+ $upId = (int)($h['id'] ?? 0);
+ if ($upId <= 0) {
+ continue;
+ }
+ $list[] = [
+ 'id' => $upId,
+ 'domain' => (string)($h['domain'] ?? ''),
+ 'productname' => (string)($h['productname'] ?? ''),
+ 'status' => (string)($h['domainstatus'] ?? ''),
+ 'status_desc' => (string)($h['domainstatus_desc'] ?? $h['domainstatus'] ?? ''),
+ 'dedicatedip' => (string)($h['dedicatedip'] ?? ''),
+ 'cycle' => (string)($h['cycle_desc'] ?? $h['billingcycle'] ?? ''),
+ 'nextdue' => zjmf_normalize_date((string)($h['nextduedate'] ?? '')),
+ 'assigned' => isset($assigned[$upId]),
+ ];
+ }
+ json_exit_success('拉取成功', [
+ 'list' => $list,
+ ]);
+});
+
+// 产品分配:把上游已开通的机器直接指派给指定用户(本地绑定,不调上游购买)
+mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
+ mnbt_plugin_require_admin();
+ global $DB;
+
+ $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
+ if (!$supplier || (int)$supplier['status'] !== 1) {
+ json_exit_error('供应商不存在或已停用');
+ }
+ $user_id = (int)($_POST['user_id'] ?? 0);
+ $user = $DB->get_row_prepare(
+ "SELECT id, username, email FROM MN_plugin_user
+ WHERE id=? AND status=1 LIMIT 1",
+ [$user_id]
+ );
+ if (!$user) {
+ json_exit_error('目标用户不存在或已禁用');
+ }
+
+ $upHostIds = [];
+ if (isset($_POST['up_host_id']) && is_array($_POST['up_host_id'])) {
+ foreach ($_POST['up_host_id'] as $v) {
+ $v = (int)$v;
+ if ($v > 0) {
+ $upHostIds[] = $v;
+ }
+ }
+ }
+ if ($upHostIds === []) {
+ json_exit_error('请至少勾选一台主机');
+ }
+
+ // 一次拉取上游主机列表,匹配勾选机器的详情(产品名/周期/状态/到期)
+ $upMap = [];
+ $res = ZjmfUpstream::hostList($supplier, ['page' => 1, 'limit' => 100]);
+ if (!empty($res['ok'])) {
+ foreach (($res['data']['list'] ?? []) as $h) {
+ $upMap[(int)($h['id'] ?? 0)] = $h;
+ }
+ }
+
+ $results = [];
+ foreach ($upHostIds as $upHostId) {
+ // 幂等:该上游主机已指派则跳过
+ $exist = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_host
+ WHERE supplier_id=? AND up_host_id=? LIMIT 1",
+ [(int)$supplier['id'], $upHostId]
+ );
+ if ($exist) {
+ $results[] = [
+ 'up_host_id' => $upHostId,
+ 'domain' => (string)($upMap[$upHostId]['domain'] ?? ''),
+ 'ok' => false,
+ 'msg' => '该机器已指派(本地主机 #' . (int)$exist['id'] . ')',
+ 'host_id' => (int)$exist['id'],
+ ];
+ continue;
+ }
+
+ $upRow = $upMap[$upHostId] ?? [];
+ $name = (string)($upRow['productname'] ?? '');
+ $cycle = (string)($upRow['cycle_desc'] ?? $upRow['billingcycle'] ?? '');
+ $status = (string)($upRow['domainstatus'] ?? '');
+ $renew = (string)($upRow['nextduedate'] ?? '');
+ $account = '';
+ $password = '';
+ // 拉取上游主机头信息(账号/密码),失败不阻断
+ // 注意:host/header 返回字段为 domainstatus/nextduedate/billingcycle/productname
+ $info = ZjmfUpstream::hostInfo($supplier, $upHostId);
+ if (!empty($info['ok']) && is_array($info['data'])) {
+ $d = $info['data'];
+ $account = (string)($d['username'] ?? '');
+ $password = (string)($d['password'] ?? '');
+ if ($name === '') {
+ $name = (string)($d['productname'] ?? $d['name'] ?? '');
+ }
+ if ($cycle === '') {
+ $cycle = (string)($d['billingcycle_desc'] ?? $d['billingcycle'] ?? $d['cycle_desc'] ?? '');
+ }
+ if ($status === '') {
+ $status = (string)($d['domainstatus'] ?? $d['status'] ?? '');
+ }
+ if ($renew === '') {
+ $renew = (string)($d['nextduedate'] ?? $d['renew_date'] ?? '');
+ }
+ }
+ if ($name === '') {
+ $name = '上游主机#' . $upHostId;
+ }
+
+ // 创建指派订单(记录用,金额 0,直接标记已开通)
+ $product = [
+ 'id' => 0,
+ 'supplier_id' => (int)$supplier['id'],
+ 'up_product_id' => (int)($upRow['pid'] ?? 0),
+ 'name' => $name,
+ ];
+ $cycleCfg = ['name' => $cycle !== '' ? $cycle : 'Monthly', 'price_cents' => 0];
+ $order = zjmf_order_create($user, $product, $cycle, $cycleCfg, 'assign', [
+ 'cost_cents' => 0,
+ 'up_host_id' => $upHostId,
+ 'order_params' => json_encode([
+ 'assign_by' => 'admin',
+ 'assign_type' => 'existing_host',
+ ], JSON_UNESCAPED_UNICODE),
+ ]);
+ if (empty($order['ok'])) {
+ $results[] = [
+ 'up_host_id' => $upHostId,
+ 'domain' => (string)($upRow['domain'] ?? ''),
+ 'ok' => false,
+ 'msg' => (string)($order['msg'] ?? '订单创建失败'),
+ 'host_id' => 0,
+ ];
+ continue;
+ }
+ $order_id = (int)$order['order_id'];
+ zjmf_order_fill_opened($order_id, 0, $upHostId, $account);
+ zjmf_order_set_status($order_id, 'opened', '管理员指派');
+
+ // 写本地主机映射(绑定该上游机器)
+ $hostId = zjmf_host_create([
+ 'supplier_id' => (int)$supplier['id'],
+ 'user_id' => (int)$user['id'],
+ 'order_id' => $order_id,
+ 'up_host_id' => $upHostId,
+ 'up_product_id' => (int)($upRow['pid'] ?? 0),
+ 'name' => $name,
+ 'username' => $account,
+ 'password' => $password !== '' ? zjmf_encrypt($password) : '',
+ 'cycle' => $cycle,
+ 'status' => zjmf_map_upstream_status($status),
+ 'renew_date' => zjmf_normalize_date($renew),
+ ]);
+ if ($hostId <= 0) {
+ $results[] = [
+ 'up_host_id' => $upHostId,
+ 'domain' => (string)($upRow['domain'] ?? ''),
+ 'ok' => false,
+ 'msg' => '本地主机映射写入失败',
+ 'host_id' => 0,
+ ];
+ continue;
+ }
+
+ zjmf_log((int)$user['id'], (string)($order['order_no'] ?? ''), 'assign', 'success',
+ json_encode(['up_host_id' => $upHostId, 'assign_by' => 'admin'], JSON_UNESCAPED_UNICODE),
+ (int)$supplier['id']);
+
+ $results[] = [
+ 'up_host_id' => $upHostId,
+ 'domain' => (string)($upRow['domain'] ?? ''),
+ 'ok' => true,
+ 'msg' => '指派成功',
+ 'host_id' => $hostId,
+ ];
+ }
+ json_exit_success('处理完成', ['results' => $results]);
+});
+
// 按所选供应商 + 勾选商品 ID 列表同步
mnbt_register_ajax('admin', 'p_zjmf_admin_sync_products', function () {
mnbt_plugin_require_admin();
@@ -287,6 +537,157 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_sync_products', function () {
json_exit_success($result['msg'] ?? '同步完成');
});
+// 产品分配:按关键词搜索可分配用户(user_info 独立用户表)
+mnbt_register_ajax('admin', 'p_zjmf_admin_search_users', function () {
+ mnbt_plugin_require_admin();
+ global $DB;
+ $kw = trim((string)($_POST['keyword'] ?? ''));
+ $list = [];
+ if ($kw !== '') {
+ $like = '%' . $kw . '%';
+ $list = $DB->get_all_prepare(
+ "SELECT id, username, email, qq, created_at
+ FROM MN_plugin_user
+ WHERE status=1 AND (username LIKE ? OR email LIKE ? OR id=?)
+ ORDER BY id DESC LIMIT 20",
+ [$like, $like, (int)$kw]
+ ) ?: [];
+ }
+ json_exit_success('ok', ['list' => $list]);
+});
+
+// 产品分配:同步勾选的上游商品并直接给指定用户开通(管理员代开,金额 0)
+mnbt_register_ajax('admin', 'p_zjmf_admin_assign_open', function () {
+ mnbt_plugin_require_admin();
+ global $DB;
+
+ $supplier = zjmf_supplier_get((int)($_POST['supplier_id'] ?? 0));
+ if (!$supplier || (int)$supplier['status'] !== 1) {
+ json_exit_error('供应商不存在或已停用');
+ }
+ $user_id = (int)($_POST['user_id'] ?? 0);
+ $user = $DB->get_row_prepare(
+ "SELECT id, username, email FROM MN_plugin_user
+ WHERE id=? AND status=1 LIMIT 1",
+ [$user_id]
+ );
+ if (!$user) {
+ json_exit_error('目标用户不存在或已禁用');
+ }
+
+ $upIds = [];
+ if (isset($_POST['up_product_id']) && is_array($_POST['up_product_id'])) {
+ foreach ($_POST['up_product_id'] as $v) {
+ $v = (int)$v;
+ if ($v > 0) {
+ $upIds[] = $v;
+ }
+ }
+ }
+ if ($upIds === []) {
+ json_exit_error('请至少勾选一个产品');
+ }
+ // 周期可选:指定周期不存在时自动回退商品第一个可用周期
+ $cycle = trim((string)($_POST['cycle'] ?? ''));
+ if ($cycle !== '' && !isset(zjmf_cycles()[$cycle])) {
+ $cycle = '';
+ }
+
+ $results = [];
+ foreach ($upIds as $upId) {
+ $product = zjmf_product_get_by_up((int)$supplier['id'], $upId);
+ if (!$product) {
+ $sync = ZjmfUpstream::syncOneProductBySupplier($supplier, $upId);
+ if (empty($sync['ok'])) {
+ $results[] = [
+ 'up_product_id' => $upId,
+ 'name' => '上游#' . $upId,
+ 'ok' => false,
+ 'msg' => '商品同步失败:' . (string)($sync['msg'] ?? ''),
+ 'up_host_id' => 0,
+ ];
+ continue;
+ }
+ $product = zjmf_product_get_by_up((int)$supplier['id'], $upId);
+ }
+ if (!$product) {
+ $results[] = [
+ 'up_product_id' => $upId,
+ 'name' => '上游#' . $upId,
+ 'ok' => false,
+ 'msg' => '商品入库后仍未找到',
+ 'up_host_id' => 0,
+ ];
+ continue;
+ }
+
+ // 确定周期:管理员指定 > 商品第一个有价格的周期 > Monthly > 任意
+ $cycles = zjmf_product_cycles($product);
+ $pick = ($cycle !== '' && isset($cycles[$cycle])) ? $cycle : '';
+ if ($pick === '') {
+ foreach ($cycles as $k => $cfg) {
+ if ((int)($cfg['agent_price_cents'] ?? 0) > 0
+ || (int)($cfg['price_cents'] ?? 0) > 0) {
+ $pick = $k;
+ break;
+ }
+ }
+ }
+ if ($pick === '') {
+ if (isset($cycles['Monthly'])) {
+ $pick = 'Monthly';
+ } else {
+ foreach ($cycles as $k => $v) {
+ $pick = $k;
+ break;
+ }
+ }
+ }
+ if ($pick === '') {
+ $results[] = [
+ 'up_product_id' => $upId,
+ 'name' => (string)$product['name'],
+ 'ok' => false,
+ 'msg' => '商品无可用周期',
+ 'up_host_id' => 0,
+ ];
+ continue;
+ }
+
+ // 管理员代开订单:金额 0,标记后直接开通(不扣用户本地余额)
+ $cycleCfg = [
+ 'name' => (string)($cycles[$pick]['name'] ?? $pick),
+ 'price_cents' => 0,
+ ];
+ $order = zjmf_order_create($user, $product, $pick, $cycleCfg, 'buy', [
+ 'cost_cents' => 0,
+ 'order_params' => json_encode(['assign_by' => 'admin'], JSON_UNESCAPED_UNICODE),
+ ]);
+ if (empty($order['ok'])) {
+ $results[] = [
+ 'up_product_id' => $upId,
+ 'name' => (string)$product['name'],
+ 'ok' => false,
+ 'msg' => (string)($order['msg'] ?? '订单创建失败'),
+ 'up_host_id' => 0,
+ ];
+ continue;
+ }
+ $order_id = (int)$order['order_id'];
+ zjmf_order_set_status($order_id, 'paid', '管理员代开');
+ $open = zjmf_open_host($order_id);
+
+ $results[] = [
+ 'up_product_id' => $upId,
+ 'name' => (string)$product['name'],
+ 'ok' => !empty($open['ok']),
+ 'msg' => (string)($open['msg'] ?? '开通失败'),
+ 'up_host_id' => (int)($open['host_id'] ?? 0),
+ ];
+ }
+ json_exit_success('处理完成', ['results' => $results]);
+});
+
// 手动添加商品(供应商 + 上游商品 ID + 名称 + 描述)
mnbt_register_ajax('admin', 'p_zjmf_admin_add_product', function () {
mnbt_plugin_require_admin();
@@ -641,16 +1042,22 @@ mnbt_register_route('GET', '/reserve/api/hosts', function ($params, $ctx) {
}
$hosts = [];
foreach (zjmf_host_list_by_user((int)$user['id']) as $h) {
+ // 缺失上游主机 ID 时尝试从上游主机列表补齐(同一次请求只拉一次上游列表)
+ $h = zjmf_backfill_host_upid($h);
+ $supplier = zjmf_supplier_get((int)$h['supplier_id']);
$hosts[] = [
'id' => (int)$h['id'],
'up_host_id' => (int)$h['up_host_id'],
+ 'up_product_id' => (int)$h['up_product_id'],
'name' => (string)$h['name'],
'username' => (string)$h['username'],
'cycle' => (string)$h['cycle'],
'status' => (string)$h['status'],
- 'renew_date' => (string)$h['renew_date'],
+ // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
+ 'renew_date' => zjmf_normalize_date((string)$h['renew_date']),
'created_at' => (string)$h['created_at'],
'updated_at' => (string)$h['updated_at'],
+ 'supplier_name' => (string)($supplier['name'] ?? ''),
];
}
zjmf_json('ok', ['logged_in' => true, 'hosts' => $hosts]);
@@ -684,9 +1091,16 @@ mnbt_register_route('GET', '/reserve/orders', function ($params, $ctx) {
// 我的主机列表
mnbt_register_route('GET', '/reserve/hosts', function ($params, $ctx) {
$user = zjmf_require_user();
+ $hosts = zjmf_host_list_by_user((int)$user['id']);
+ // 列表页顺带补齐缺失的上游主机 ID(同一次请求只拉一次上游列表)
+ foreach ($hosts as $i => $h) {
+ $hosts[$i] = zjmf_backfill_host_upid($h);
+ // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
+ $hosts[$i]['renew_date'] = zjmf_normalize_date((string)$hosts[$i]['renew_date']);
+ }
zjmf_render('hosts', [
'page_title' => '我的主机',
- 'hosts' => zjmf_host_list_by_user((int)$user['id']),
+ 'hosts' => $hosts,
]);
});
@@ -701,25 +1115,75 @@ mnbt_register_route('GET', '/reserve/hosts/{host_id}', function ($params, $ctx)
return;
}
+ // 缺失上游主机 ID 时尝试补齐(开通结算未解析出 ID 的历史数据)
+ $host = zjmf_backfill_host_upid($host);
+ // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
+ $host['renew_date'] = zjmf_normalize_date((string)$host['renew_date']);
+
// 实时信息(失败不致命,仅展示缓存)
$info = ['ok' => false, 'msg' => ''];
$traffic = ['ok' => false, 'data' => []];
+ $dcim = ['ok' => false, 'data' => []];
+ $osList = [];
+ $osGroups = [];
+ $osError = '';
$supplier = zjmf_supplier_get((int)$host['supplier_id']);
if ((int)$host['up_host_id'] > 0 && $supplier) {
- $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
- $traffic = ZjmfUpstream::hostTraffic($supplier, (int)$host['up_host_id']);
- // 拉取成功后同步缓存状态
- if (!empty($info['ok']) && $info['status'] !== $host['status']) {
- zjmf_host_update_cache((int)$host['id'], ['status' => $info['status']]);
- $host['status'] = $info['status'];
+ try {
+ $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
+ $traffic = ZjmfUpstream::hostTraffic($supplier, (int)$host['up_host_id']);
+ // DCIM 信息(交换机端口/电源状态/重装次数/任务进度),非 DCIM 产品为空,失败不致命
+ $dcim = ZjmfUpstream::hostDcimInfo($supplier, (int)$host['up_host_id']);
+ // 重装系统列表(GET host/dedicatedserver?host_id=,实测可返回 cloud_os),
+ // 失败回退 host/product 的 dcim.os
+ $osList = is_array($info['dcim']['os'] ?? null) ? $info['dcim']['os'] : [];
+ $osGroups = [];
+ $osError = '';
+ $co = ZjmfUpstream::hostDedicatedOs($supplier, (int)$host['up_host_id']);
+ if (!empty($co['ok']) && $co['os_list'] !== []) {
+ $osList = $co['os_list'];
+ $osGroups = $co['groups'];
+ } else {
+ $osError = (string)($co['msg'] ?? '获取系统列表失败');
+ }
+ // 详情端点取不到状态时,从 host/list(含 domainstatus)按 ID 匹配兜底
+ if (($info['status'] ?? '') === 'unknown') {
+ $lr = ZjmfUpstream::hostList($supplier, ['limit' => 100]);
+ if (!empty($lr['ok'])) {
+ foreach (($lr['data']['list'] ?? []) as $it) {
+ if ((int)($it['id'] ?? 0) === (int)$host['up_host_id']) {
+ $mapped = zjmf_map_upstream_status((string)($it['domainstatus'] ?? ''));
+ if ($mapped !== 'unknown') {
+ $info['status'] = $mapped;
+ $info['data'] = is_array($info['data']) ? $info['data'] : [];
+ $info['data'] = array_merge($info['data'], $it);
+ }
+ break;
+ }
+ }
+ }
+ }
+ // 拉取成功后同步缓存状态(unknown 不上写,避免误覆盖)
+ if (!empty($info['ok']) && $info['status'] !== 'unknown' && $info['status'] !== $host['status']) {
+ zjmf_host_update_cache((int)$host['id'], ['status' => $info['status']]);
+ $host['status'] = $info['status'];
+ }
+ } catch (Throwable $e) {
+ error_log('[zjmfmanager_reserve] host detail upstream: ' . $e->getMessage());
}
}
zjmf_render('host', [
- 'page_title' => '主机详情:' . $host['name'],
- 'host' => $host,
- 'info' => $info,
- 'traffic' => $traffic,
+ 'page_title' => '主机详情:' . $host['name'],
+ 'host' => $host,
+ 'info' => $info,
+ 'traffic' => $traffic,
+ 'dcim' => $dcim,
+ 'config_options'=> is_array($info['config_options'] ?? null) ? $info['config_options'] : [],
+ 'custom_fields' => is_array($info['custom_fields'] ?? null) ? $info['custom_fields'] : [],
+ 'os_list' => $osList,
+ 'os_groups' => $osGroups,
+ 'os_error' => $osError,
]);
});
@@ -742,6 +1206,53 @@ mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx)
}
$supplier = zjmf_supplier_get((int)$host['supplier_id']);
+ // DCIM 专用操作(救援系统/重置BMC/取消任务)
+ $dcimActions = ['rescue', 'bmc', 'cancel_task'];
+ if (in_array($action, $dcimActions, true)) {
+ $extra = [];
+ $apiAction = $action;
+ if ($action === 'rescue') {
+ $system = (int)($_POST['system'] ?? 0);
+ if ($system !== 1 && $system !== 2) {
+ zjmf_json('请选择救援系统(1 Linux / 2 Windows)');
+ }
+ $extra['system'] = $system;
+ }
+ $result = ZjmfUpstream::hostDcimAction($supplier, (int)$host['up_host_id'], $apiAction, $extra);
+ $hostOrder = zjmf_order_get((int)$host['order_id']);
+ $orderNo = $hostOrder ? $hostOrder['order_no'] : '';
+ zjmf_log((int)$user['id'], $orderNo, 'host_action:' . $action,
+ empty($result['ok']) ? 'failed' : 'success', $result['msg'] ?? '',
+ (int)$host['supplier_id']);
+ if (empty($result['ok'])) {
+ zjmf_json($result['msg'] ?? '操作失败');
+ }
+ zjmf_json('ok', ['msg' => '操作成功']);
+ }
+
+ // 重装系统:POST /provision/default (func=reinstall),只需 os 与 os_group,无需密码/端口
+ if ($action === 'dcim_reinstall') {
+ $os = (int)($_POST['os'] ?? 0);
+ if ($os <= 0) {
+ zjmf_json('请选择操作系统');
+ }
+ $extra = ['os' => $os, 'code' => 0];
+ $osGroup = trim((string)($_POST['os_group'] ?? ''));
+ if ($osGroup !== '') {
+ $extra['os_group'] = $osGroup;
+ }
+ $result = ZjmfUpstream::hostAction($supplier, (int)$host['up_host_id'], 'reinstall', $extra);
+ $hostOrder = zjmf_order_get((int)$host['order_id']);
+ $orderNo = $hostOrder ? $hostOrder['order_no'] : '';
+ zjmf_log((int)$user['id'], $orderNo, 'host_action:dcim_reinstall',
+ empty($result['ok']) ? 'failed' : 'success', $result['msg'] ?? '',
+ (int)$host['supplier_id']);
+ if (empty($result['ok'])) {
+ zjmf_json($result['msg'] ?? '操作失败');
+ }
+ zjmf_json('ok', ['msg' => '操作成功']);
+ }
+
$func = zjmf_action_func($action);
if ($func === '') {
zjmf_json('不支持的操作');
@@ -931,7 +1442,9 @@ mnbt_register_route('POST', '/reserve/api/upgrade', function ($params, $ctx) {
$cache['up_product_id'] = (int)($_POST['newpid'] ?? 0);
}
$cache['cycle'] = (string)($_POST['billingcycle'] ?? $host['cycle']);
- $cache['renew_date'] = (string)($preview['data']['renew_date'] ?? '');
+ $cache['renew_date'] = zjmf_normalize_date(
+ (string)($preview['data']['renew_date'] ?? '')
+ );
zjmf_host_update_cache((int)$host['id'], $cache);
zjmf_log((int)$user['id'], $order_no, $action, 'success',
@@ -966,11 +1479,116 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_fetch_host', function () {
if (empty($info['ok'])) {
json_exit_error($info['msg'] ?? '查询失败');
}
- $cache = ['status' => $info['status']];
- if (isset($info['data']['renewdate']) || isset($info['data']['renew_date'])) {
- $cache['renew_date'] = (string)($info['data']['renew_date']
- ?? $info['data']['renewdate'] ?? '');
+ $cache = [];
+ if ($info['status'] !== 'unknown') {
+ $cache['status'] = $info['status'];
+ }
+ if (isset($info['data']['nextduedate']) || isset($info['data']['renewdate']) || isset($info['data']['renew_date'])) {
+ $cache['renew_date'] = zjmf_normalize_date(
+ (string)($info['data']['nextduedate'] ?? $info['data']['renew_date'] ?? $info['data']['renewdate'] ?? '')
+ );
+ }
+ if ($cache) {
+ zjmf_host_update_cache($id, $cache);
}
- zjmf_host_update_cache($id, $cache);
json_exit_success('已刷新', ['status' => $info['status']]);
});
+
+// 全部刷新主机状态(管理员):拉取 host/list 全量按 ID 匹配批量更新,
+// 修复存量 unknown/过期缓存(比逐台 hostInfo 快且可靠)
+mnbt_register_ajax('admin', 'p_zjmf_admin_fetch_all_hosts', function () {
+ mnbt_plugin_require_admin();
+
+ // 拉取本地全部主机
+ $hosts = [];
+ $page = 1;
+ $per = 200;
+ while (true) {
+ $res = zjmf_host_list_all($page, $per);
+ $list = $res['list'] ?? [];
+ if ($list === []) {
+ break;
+ }
+ foreach ($list as $h) {
+ $hosts[] = $h;
+ }
+ if ($page * $per >= (int)($res['total'] ?? 0)) {
+ break;
+ }
+ $page++;
+ }
+
+ // 按供应商拉取上游主机列表(分页全量)
+ $supplierIds = [];
+ foreach ($hosts as $h) {
+ $sid = (int)$h['supplier_id'];
+ if ($sid > 0) {
+ $supplierIds[$sid] = true;
+ }
+ }
+ $upMap = [];
+ foreach (array_keys($supplierIds) as $sid) {
+ $supplier = zjmf_supplier_get($sid);
+ if (!$supplier) {
+ continue;
+ }
+ $items = [];
+ $sum = 0;
+ $maxPage = 1;
+ $p = 1;
+ $safety = 30;
+ while ($p <= $maxPage && $safety-- > 0) {
+ $r = ZjmfUpstream::hostList($supplier, ['page' => $p, 'limit' => 100]);
+ if (empty($r['ok'])) {
+ break;
+ }
+ $d = $r['data'] ?? [];
+ foreach (($d['list'] ?? []) as $it) {
+ $items[(int)($it['id'] ?? 0)] = $it;
+ }
+ $sum = (int)($d['sum'] ?? count($items));
+ $maxPage = (int)($d['max_page'] ?? 1);
+ if ($maxPage <= 0) {
+ $maxPage = 1;
+ }
+ if ($sum > 0 && count($items) >= $sum) {
+ break;
+ }
+ $p++;
+ }
+ $upMap[$sid] = $items;
+ }
+
+ $stat = ['total' => count($hosts), 'ok' => 0, 'fail' => 0, 'no_up' => 0,
+ 'missing' => 0, 'unknown' => 0, 'changed' => 0];
+ foreach ($hosts as $host) {
+ $upId = (int)$host['up_host_id'];
+ if ($upId <= 0) {
+ $stat['no_up']++;
+ continue;
+ }
+ $item = $upMap[(int)$host['supplier_id']][$upId] ?? null;
+ if (!$item) {
+ $stat['missing']++;
+ continue;
+ }
+ $status = zjmf_map_upstream_status((string)($item['domainstatus'] ?? ''));
+ $renew = zjmf_normalize_date((string)($item['nextduedate'] ?? ''));
+ $cache = [];
+ if ($status !== 'unknown' && $status !== (string)$host['status']) {
+ $cache['status'] = $status;
+ $stat['changed']++;
+ }
+ if ($renew !== '' && $renew !== (string)$host['renew_date']) {
+ $cache['renew_date'] = $renew;
+ }
+ if ($cache) {
+ zjmf_host_update_cache((int)$host['id'], $cache);
+ }
+ if ($status === 'unknown') {
+ $stat['unknown']++;
+ }
+ $stat['ok']++;
+ }
+ json_exit_success('刷新完成', $stat);
+});
diff --git a/app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php b/app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php
index d1c848c..4b6ea74 100644
--- a/app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php
+++ b/app_plugins/zjmfmanager_reserve/lib/CubeFinanceClient.php
@@ -257,6 +257,65 @@ class CubeFinanceClient
return $this->get('cart/set_config', $params);
}
+ /**
+ * 购物车计算总价(官方价格计算接口)。
+ * POST /cart/get_total
+ *
+ * @param array $params 通常含 pid、billingcycle、qty、configoption 等
+ * @return array
+ */
+ public function cartGetTotal(array $params)
+ {
+ return $this->post('cart/get_total', $params);
+ }
+
+ /**
+ * 购物车产品配置(含该产品可选周期/价格,用于探测真实 billingcycle 键)。
+ * GET /cart/get_product_config?pid={pid}
+ *
+ * @param int $productId
+ * @return array
+ */
+ public function cartGetProductConfig($productId)
+ {
+ return $this->get('cart/get_product_config', [
+ 'pid' => (int)$productId,
+ ]);
+ }
+
+ /**
+ * 清空购物车。
+ * POST /cart/clear
+ *
+ * @return array
+ */
+ public function cartClear()
+ {
+ return $this->post('cart/clear', []);
+ }
+
+ /**
+ * 购物车页面数据(含购物车产品列表,数组键/序号即位置 i)。
+ * GET /cart/get_shop_data
+ *
+ * @return array
+ */
+ public function cartGetShopData()
+ {
+ return $this->get('cart/get_shop_data');
+ }
+
+ /**
+ * 前台首页数据(含客户余额 data.client[].credit)。
+ * GET /index
+ *
+ * @return array
+ */
+ public function homeIndex()
+ {
+ return $this->get('index');
+ }
+
/**
* 账户信息 / 余额。
* GET user_info
@@ -277,7 +336,39 @@ class CubeFinanceClient
*/
public function hostHeader($hostId)
{
- return $this->get('host/header', ['host_id' => intval($hostId)]);
+ return $this->get('host/header', ['host_id' => (int)$hostId]);
+ }
+
+ /** 产品内页数据(GET host/product),状态/账号等字段更稳定。 */
+ public function hostProduct($hostId)
+ {
+ return $this->get('host/product', ['host_id' => (int)$hostId]);
+ }
+
+ /**
+ * 独服/云服务器详情(含重装可选系统列表 cloud_os / cloud_os_group)。
+ * GET host/dedicatedserver?host_id={hostId}
+ *
+ * 实测 host/cloudos 无法返回系统列表,该端点是重装系统的可靠来源。
+ *
+ * @param int $hostId 上游主机 ID
+ * @return array
+ */
+ public function hostDedicatedServer($hostId)
+ {
+ return $this->get('host/dedicatedserver', ['host_id' => (int)$hostId]);
+ }
+
+ /**
+ * 我的主机列表。
+ * GET host/list
+ *
+ * @param array $extra 如 groupid/orderby/sort/show_type
+ * @return array
+ */
+ public function hostList(array $extra = [])
+ {
+ return $this->get('host/list', $extra);
}
/**
@@ -306,6 +397,56 @@ class CubeFinanceClient
return $this->post('provision/default', $params);
}
+ /* ============================================================
+ * DCIM 管理(服务器租用 / NAT 类产品)
+ * ============================================================ */
+
+ /**
+ * DCIM 模块操作(开机/关机/重启/重置BMC/救援/取消任务等)。
+ * POST dcim/{action}?id={hostId}
+ *
+ * @param string $action 端点名(on/off/reboot/bmc/rescue/cancel_task/reinstall/crack_pass)
+ * @param int $hostId 上游主机 ID
+ * @param array $extra 额外参数
+ * @return array
+ */
+ public function dcimAction($action, $hostId, array $extra = [])
+ {
+ $data = array_merge(['id' => (int)$hostId], $extra);
+ return $this->post('dcim/' . $action, $data);
+ }
+
+ /** DCIM 产品详情(交换机/端口信息)。GET dcim/detail */
+ public function dcimDetail($hostId)
+ {
+ return $this->get('dcim/detail', ['id' => (int)$hostId]);
+ }
+
+ /** 单台电源状态。POST dcim/refresh_power_status */
+ public function dcimPowerStatus($hostId)
+ {
+ return $this->post('dcim/refresh_power_status', ['id' => (int)$hostId]);
+ }
+
+ /** 验证是否可以重装(本周次数/上限)。POST dcim/check_reinstall */
+ public function dcimCheckReinstall($hostId)
+ {
+ return $this->post('dcim/check_reinstall', ['id' => (int)$hostId]);
+ }
+
+ /** 重装/救援/重置密码进度。GET dcim/resintall_status */
+ public function dcimReinstallStatus($hostId)
+ {
+ return $this->get('dcim/resintall_status', ['id' => (int)$hostId]);
+ }
+
+ /** 用量信息。GET dcim/traffic_usage */
+ public function dcimTrafficUsage($hostId, array $extra = [])
+ {
+ $data = array_merge(['id' => (int)$hostId], $extra);
+ return $this->get('dcim/traffic_usage', $data);
+ }
+
/**
* 配置升级页。
* GET upgrade/index/{hostId}
@@ -541,6 +682,17 @@ class CubeFinanceClient
@unlink($file);
}
}
+
+ /**
+ * 修改请求超时秒数(用户端实时查询用较短超时,避免拖垮整页加载)。
+ *
+ * @param int $seconds
+ * @return void
+ */
+ public function setTimeout($seconds)
+ {
+ $this->timeout = max(1, (int)$seconds);
+ }
}
}
diff --git a/app_plugins/zjmfmanager_reserve/lib/upstream.php b/app_plugins/zjmfmanager_reserve/lib/upstream.php
index d413136..5690c51 100644
--- a/app_plugins/zjmfmanager_reserve/lib/upstream.php
+++ b/app_plugins/zjmfmanager_reserve/lib/upstream.php
@@ -33,9 +33,10 @@ class ZjmfUpstream
* JWT 缓存目录按供应商 ID 隔离,避免多供应商凭证串扰。
*
* @param array|null $supplier MN_plugin_zjmf_supplier 行
+ * @param int|null $timeout 覆盖超时秒数(null 用供应商配置 api_timeout)
* @return CubeFinanceClient|null
*/
- public static function client($supplier)
+ public static function client($supplier, $timeout = null)
{
if (!is_array($supplier)) {
return null;
@@ -46,6 +47,11 @@ class ZjmfUpstream
if ($apiUrl === '' || $username === '' || $password === '') {
return null;
}
+ $t = (int)($supplier['api_timeout'] ?? 30);
+ $t = $t > 0 ? $t : 30;
+ if ($timeout !== null && (int)$timeout > 0) {
+ $t = (int)$timeout;
+ }
$supplierId = (int)($supplier['id'] ?? 0);
$cacheDir = mnbt_plugin_path('zjmfmanager_reserve')
. 'runtime/cache/s' . $supplierId;
@@ -53,8 +59,7 @@ class ZjmfUpstream
'url' => $apiUrl,
'username' => $username,
'password' => $password,
- 'timeout' => (int)($supplier['api_timeout'] ?? 30) > 0
- ? (int)($supplier['api_timeout'] ?? 30) : 30,
+ 'timeout' => $t,
'cache_dir' => $cacheDir,
'verify_ssl' => false,
]);
@@ -70,6 +75,67 @@ class ZjmfUpstream
return $client->testConnection();
}
+ /**
+ * 查询供应商上游账户余额(GET /index → data.client[].credit)。
+ * user_info 的 data.credit 是信用卡信息而非余额,故优先取首页客户数据,
+ * 失败再兜底 user_info。
+ *
+ * @param array $supplier 供应商行
+ * @return array ['ok'=>bool, 'msg'=>string, 'credit'=>string, 'currency'=>string]
+ */
+ public static function balance($supplier)
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $client->homeIndex();
+ if (self::respOk($res)) {
+ $clients = $res['data']['client'] ?? [];
+ // 兼容单对象(非数组)形态
+ if (is_array($clients) && !isset($clients[0]) && ($clients['credit'] ?? '') !== '') {
+ return [
+ 'ok' => true,
+ 'msg' => 'ok',
+ 'credit' => (string)$clients['credit'],
+ 'currency' => (string)($clients['currency'] ?? ''),
+ ];
+ }
+ if (is_array($clients)) {
+ foreach ($clients as $c) {
+ if (is_array($c) && ($c['credit'] ?? '') !== '') {
+ return [
+ 'ok' => true,
+ 'msg' => 'ok',
+ 'credit' => (string)$c['credit'],
+ 'currency' => (string)($c['currency'] ?? ''),
+ ];
+ }
+ }
+ }
+ }
+ // 兜底:GET /user_info(部分版本 credit 即余额)
+ $res = $client->userInfo();
+ if (!self::respOk($res)) {
+ return ['ok' => false, 'msg' => self::respErr('获取上游账户信息失败', $res)];
+ }
+ $data = $res['data'] ?? [];
+ $credit = $data['credit'] ?? null;
+ if ($credit === null || $credit === '') {
+ $credit = $data['credit_balance'] ?? null; // 兼容字段
+ }
+ return [
+ 'ok' => true,
+ 'msg' => 'ok',
+ 'credit' => (string)$credit,
+ 'currency' => (string)($data['currency'] ?? ''),
+ ];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
/* ============================================================
* 商品同步
* ============================================================ */
@@ -102,14 +168,15 @@ class ZjmfUpstream
*/
public static function syncOneProductBySupplier($supplier, $upProductId)
{
+ @set_time_limit(0);
$client = self::client($supplier);
if (!$client) {
return ['ok' => false, 'msg' => '供应商连接信息不完整'];
}
try {
$upId = (int)$upProductId;
- // 优先从列表取真实条目(含 billingcycle/price 等周期信息),失败再退回详情
- $item = null;
+ // 优先从列表取真实条目(含 billingcycle/price 等周期信息,同 pid 可能每周期一行),失败再退回详情
+ $rows = [];
$currency = '';
try {
$res = $client->productList();
@@ -117,26 +184,25 @@ class ZjmfUpstream
$currency = (string)($res['data']['currency_code'] ?? '');
foreach ($res['data']['list'] as $row) {
if ((int)($row['id'] ?? 0) === $upId) {
- $item = $row;
- break;
+ $rows[] = $row;
}
}
}
} catch (CubeFinanceException $e) {
// 列表失败忽略,退回详情
}
- if (!$item) {
+ if ($rows === []) {
$detail = $client->productDetail($upId, 'agent');
$prod = $detail['data']['product'] ?? ($detail['data'] ?? []);
- $item = [
+ $rows = [[
'id' => $upId,
'name' => (string)($prod['name'] ?? ''),
'description' => (string)($prod['description'] ?? ''),
- ];
+ ]];
$currency = (string)($detail['data']['currency_code'] ?? $currency);
}
$ok = self::syncOneProduct(
- $client, (int)($supplier['id'] ?? 0), $upId, $item, $currency
+ $client, (int)($supplier['id'] ?? 0), $upId, $rows, $currency
);
return $ok
? ['ok' => true, 'msg' => '商品价格已同步']
@@ -176,6 +242,7 @@ class ZjmfUpstream
*/
public static function syncProducts($supplier, array $upIds = [])
{
+ @set_time_limit(0);
$client = self::client($supplier);
if (!$client) {
return ['ok' => false, 'msg' => '供应商连接信息不完整'];
@@ -190,6 +257,8 @@ class ZjmfUpstream
$count = 0;
$fail = 0;
$skip = 0;
+ // 上游列表可能每个周期一行(同 pid 多行),按 pid 分组后合并周期,避免相互覆盖
+ $grouped = [];
foreach ($res['data']['list'] as $item) {
$upId = (int)($item['id'] ?? 0);
if ($upId <= 0) {
@@ -199,7 +268,10 @@ class ZjmfUpstream
$skip++;
continue;
}
- if (self::syncOneProduct($client, $supplierId, $upId, $item, $currency)) {
+ $grouped[$upId][] = $item;
+ }
+ foreach ($grouped as $upId => $rows) {
+ if (self::syncOneProduct($client, $supplierId, (int)$upId, $rows, $currency)) {
$count++;
} else {
$fail++;
@@ -215,11 +287,12 @@ class ZjmfUpstream
}
}
- /** 同步单个商品。 */
- protected static function syncOneProduct($client, $supplierId, $upId, $item, $currency)
+ /** 同步单个商品(rows 为同一 pid 的列表行,可能每周期一行)。 */
+ protected static function syncOneProduct($client, $supplierId, $upId, array $rows, $currency)
{
global $DB, $date;
$now = $date ?: date('Y-m-d H:i:s');
+ $item = $rows[0] ?? [];
// 代理价(详情接口,失败回退列表项 price)
$agentCents = 0;
@@ -232,31 +305,79 @@ class ZjmfUpstream
// 详情失败不致命,仅代理价缺失
}
if ($agentCents <= 0) {
- $agentCents = self::itemPrice($item);
- }
-
- // 各周期价:优先读上游返回的 cycle 字段(列表项 → 详情),均无则回退购物车试算
- $cycles = self::parseItemCycles($item);
- if ($cycles === []) {
- $cycles = $detail ? self::parseDetailCycles($detail) : [];
- }
- if ($cycles === []) {
- foreach (zjmf_cycles() as $cycle => $cfg) {
- try {
- $trial = $client->cartSetConfig(['pid' => $upId, 'billingcycle' => $cycle]);
- $price = self::parseTrialPrice($trial);
- if ($price > 0) {
- $cycles[$cycle] = [
- 'cycle' => $cycle,
- 'name' => $cfg['name'],
- 'agent_price_cents' => $price,
- ];
- }
- } catch (CubeFinanceException $e) {
- continue;
+ foreach ($rows as $row) {
+ $agentCents = self::itemPrice($row);
+ if ($agentCents > 0) {
+ break;
+ }
+ }
+ }
+
+ // 各周期价:列表行 → 详情 → 购物车算价,逐级补充合并(列表常只有单周期字段)
+ $cycles = [];
+ foreach ($rows as $row) {
+ foreach (self::parseItemCycles($row) as $k => $entry) {
+ if (!isset($cycles[$k])) {
+ $cycles[$k] = $entry;
+ }
+ }
+ }
+ if ($detail) {
+ foreach (self::parseDetailCycles($detail) as $k => $entry) {
+ if (!isset($cycles[$k])) {
+ $cycles[$k] = $entry;
}
}
}
+ // 探测购物车配置接口:一次请求拿到全部可选周期的真实 billingcycle 键与价格。
+ // 列表/详情已有周期保持不变;仅补充缺失周期,并给缺失 up_cycle 的条目补上真实键。
+ $probedKeys = [];
+ foreach (self::probeCycles($client, $upId, $detail) as $key => $cfg) {
+ $entry = self::cycleEntry($key, (int)($cfg['price_cents'] ?? 0), (string)($cfg['name'] ?? ''));
+ $k = $entry['cycle'];
+ $probedKeys[$k] = (string)$key;
+ if (isset($cycles[$k])) {
+ if ((string)($cycles[$k]['up_cycle'] ?? '') === '') {
+ $cycles[$k]['up_cycle'] = (string)$key;
+ }
+ continue;
+ }
+ if ($entry['agent_price_cents'] <= 0) {
+ continue;
+ }
+ $cycles[$k] = $entry;
+ }
+ // 兜底:购物车算价补充缺失周期。先探测一个周期,失败即放弃该商品试算
+ //(避免对每个缺失周期都发请求,商品多时同步被拖垮)
+ $trialOn = false;
+ foreach (zjmf_cycles() as $cycle => $cfg) {
+ if (isset($cycles[$cycle])) {
+ continue;
+ }
+ if (!$trialOn) {
+ $probe = self::trialPrice($client, $upId, $cycle);
+ if ($probe <= 0) {
+ break;
+ }
+ $trialOn = true;
+ $cycles[$cycle] = [
+ 'cycle' => $cycle,
+ 'up_cycle' => $probedKeys[$cycle] ?? strtolower($cycle),
+ 'name' => $cfg['name'],
+ 'agent_price_cents' => $probe,
+ ];
+ continue;
+ }
+ $price = self::trialPrice($client, $upId, $cycle);
+ if ($price > 0) {
+ $cycles[$cycle] = [
+ 'cycle' => $cycle,
+ 'up_cycle' => $probedKeys[$cycle] ?? strtolower($cycle),
+ 'name' => $cfg['name'],
+ 'agent_price_cents' => $price,
+ ];
+ }
+ }
$cyclesJson = json_encode(array_values($cycles), JSON_UNESCAPED_UNICODE);
$existing = zjmf_product_get_by_up($supplierId, $upId);
@@ -367,31 +488,79 @@ class ZjmfUpstream
}
try {
+ // 0. 清空购物车:该版本 settle(checkout=1) 会结算整辆购物车,
+ // 若残留历史测试商品会把多件一起结算开通(曾实测一次开出多台机器)。
+ // 先清空保证本次结算只涉及刚添加的这一件商品。
+ try {
+ $client->cartClear();
+ } catch (CubeFinanceException $e) {
+ // 清空失败不致命,继续尝试(可能购物车本就为空)
+ }
+
// 1. 添加产品至购物车(官方:POST /cart/add_to_shop)→ data.i 购物车位置
+ $upCycle = self::upstreamCycle((int)($order['supplier_id'] ?? 0), $upProductId, $cycle);
$addParams = [
'pid' => $upProductId,
- 'billingcycle' => $cycle,
+ 'billingcycle' => $upCycle,
'qty' => 1,
'host' => (string)($extra['host'] ?? self::randHost()),
'password' => (string)($extra['password'] ?? self::randPassword()),
];
- foreach (['configoption', 'customfield', 'serverid', 'os'] as $k) {
- if (isset($extra[$k])) {
+ foreach (['configoption', 'customfield', 'serverid', 'os', 'currencyid'] as $k) {
+ if (isset($extra[$k]) && $extra[$k] !== '') {
$addParams[$k] = $extra[$k];
}
}
+ // 缺少 currencyid / configoption 时探测上游默认配置:
+ // 有配置项的商品必须传 configoption(子项ID/数量),否则计价失败报
+ // "此周期未配置价格或价格错误,请重新选择周期"(实测上游前台抓包确认)
+ if (!isset($addParams['currencyid']) || !isset($addParams['configoption'])) {
+ $cfgDefaults = self::probeConfigDefaults($client, $upProductId);
+ if (!isset($addParams['currencyid']) && !empty($cfgDefaults['currencyid'])) {
+ $addParams['currencyid'] = (int)$cfgDefaults['currencyid'];
+ }
+ if (!isset($addParams['configoption']) && !empty($cfgDefaults['configoption'])) {
+ $addParams['configoption'] = $cfgDefaults['configoption'];
+ }
+ }
$res = $client->post('cart/add_to_shop', $addParams);
if (!self::respOk($res)) {
- return ['ok' => false, 'msg' => self::respErr('上游添加购物车失败', $res)];
+ // 上游拒绝(多为"此周期未配置价格"):探测商品真实周期键,命中则重试一次,
+ // 并把正确键写回商品周期(后续购买直接可用)
+ $avail = [];
+ if (self::isCyclePriceError($res)) {
+ $avail = self::probeCycles($client, $upProductId);
+ $matched = self::matchUpCycle($cycle, $avail);
+ if ($matched !== '' && $matched !== $upCycle) {
+ $addParams['billingcycle'] = $matched;
+ self::persistUpCycle(
+ (int)($order['supplier_id'] ?? 0),
+ $upProductId,
+ $cycle,
+ $matched
+ );
+ $upCycle = $matched;
+ $res = $client->post('cart/add_to_shop', $addParams);
+ }
+ }
+ if (!self::respOk($res)) {
+ $msg = self::respErr('上游添加购物车失败(周期: ' . $upCycle . ')', $res);
+ if ($avail !== []) {
+ $msg .= self::formatAvailable($avail);
+ }
+ return ['ok' => false, 'msg' => $msg];
+ }
}
- $position = -1;
- if (is_array($res['data'] ?? null)) {
- $position = (int)($res['data']['i'] ?? -1);
- } elseif (is_numeric($res['data'] ?? null)) {
- $position = (int)$res['data'];
+ $position = self::findPosition($res);
+ if ($position < 0) {
+ // 该版本 add_to_shop 不返回位置(响应仅 status/msg/is_aff):
+ // 从购物车数据(GET /cart/get_shop_data,数组键/序号即位置 i)定位刚添加的产品
+ $position = self::cartPosition($client, $upProductId, $upCycle);
}
if ($position < 0) {
- return ['ok' => false, 'msg' => '上游添加购物车未返回位置,无法结算'];
+ $raw = json_encode($res['data'] ?? $res, JSON_UNESCAPED_UNICODE);
+ return ['ok' => false, 'msg' => '上游添加购物车后无法定位购物车位置(响应: '
+ . self::truncate((string)$raw, 300) . ')'];
}
// 2. 结算购物车(官方:POST /cart/settle,checkout=1 直接结算)→ data.invoiceid
@@ -469,30 +638,61 @@ class ZjmfUpstream
* 主机查询与操作
* ============================================================ */
- /** 主机头信息(状态/账号/到期),按主机所属供应商。 */
- public static function hostInfo($supplier, $upHostId)
+ /** 拉取上游我的主机列表(GET host/list),按供应商。用户端读取用短超时,避免拖垮页面。 */
+ public static function hostList($supplier, array $extra = [])
{
- $client = self::client($supplier);
+ $client = self::client($supplier, 8);
if (!$client) {
return ['ok' => false, 'msg' => '供应商连接信息不完整'];
}
try {
- $res = $client->hostHeader((int)$upHostId);
- if (($res['status'] ?? 0) != 200) {
- return ['ok' => false, 'msg' => (string)($res['msg'] ?? '查询失败')];
+ $res = $client->hostList($extra);
+ if (!self::respOk($res)) {
+ return ['ok' => false, 'msg' => self::respErr('获取上游主机列表失败', $res)];
}
- $data = $res['data'] ?? [];
- $data = self::pickHostData($data);
- return ['ok' => true, 'data' => $data, 'status' => self::mapHostStatus($data)];
+ return ['ok' => true, 'data' => $res['data'] ?? []];
} catch (CubeFinanceException $e) {
return ['ok' => false, 'msg' => $e->getMessage()];
}
}
- /** 流量使用,按主机所属供应商。 */
+ /** 主机头信息(状态/账号/到期),按主机所属供应商。用户端读取用短超时,避免拖垮页面。 */
+ public static function hostInfo($supplier, $upHostId)
+ {
+ $client = self::client($supplier, 8);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ // host/product 优先(状态等字段更稳定),失败回退 host/header
+ foreach (['hostProduct', 'hostHeader'] as $method) {
+ try {
+ $res = $client->{$method}((int)$upHostId);
+ if (($res['status'] ?? 0) == 200) {
+ $resData = $res['data'] ?? [];
+ $data = self::pickHostData($resData);
+ return [
+ 'ok' => true,
+ 'data' => $data,
+ 'config_options' => isset($resData['config_options']) && is_array($resData['config_options'])
+ ? $resData['config_options'] : [],
+ 'custom_fields' => isset($resData['custom_field_data']) && is_array($resData['custom_field_data'])
+ ? $resData['custom_field_data'] : [],
+ 'dcim' => isset($resData['dcim']) && is_array($resData['dcim'])
+ ? $resData['dcim'] : [],
+ 'status' => self::mapHostStatus($data),
+ ];
+ }
+ } catch (CubeFinanceException $e) {
+ // 尝试下一个端点
+ }
+ }
+ return ['ok' => false, 'msg' => '查询失败'];
+ }
+
+ /** 流量使用,按主机所属供应商。用户端读取用短超时,避免拖垮页面。 */
public static function hostTraffic($supplier, $upHostId)
{
- $client = self::client($supplier);
+ $client = self::client($supplier, 8);
if (!$client) {
return ['ok' => false, 'msg' => '供应商连接信息不完整'];
}
@@ -530,9 +730,93 @@ class ZjmfUpstream
}
}
- /* ============================================================
- * 升级(配置升级 / 产品升降级)
- * ============================================================ */
+ /**
+ * DCIM 综合信息(交换机端口 / 电源状态 / 重装次数 / 任务进度)。
+ * 单项失败不致命,仅用于展示;非 DCIM 产品会全部为空。
+ */
+ public static function hostDcimInfo($supplier, $upHostId)
+ {
+ // 短超时,避免叠加请求拖慢详情页
+ $client = self::client($supplier, 5);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ $out = [
+ 'detail' => [],
+ 'power' => '',
+ 'power_msg' => '',
+ 'reinstall' => [],
+ 'task' => [],
+ ];
+ $tasks = [
+ 'detail' => function () use ($client, $upHostId) { return $client->dcimDetail((int)$upHostId); },
+ 'power' => function () use ($client, $upHostId) { return $client->dcimPowerStatus((int)$upHostId); },
+ 'reinstall' => function () use ($client, $upHostId) { return $client->dcimCheckReinstall((int)$upHostId); },
+ 'task' => function () use ($client, $upHostId) { return $client->dcimReinstallStatus((int)$upHostId); },
+ ];
+ foreach ($tasks as $k => $fn) {
+ try {
+ $res = $fn();
+ if (($res['status'] ?? 0) == 200) {
+ $d = $res['data'] ?? [];
+ if ($k === 'power') {
+ $out['power'] = (string)($d['power'] ?? '');
+ $out['power_msg'] = (string)($d['msg'] ?? '');
+ } else {
+ $out[$k] = $d;
+ }
+ }
+ } catch (CubeFinanceException $e) {
+ // 单项失败忽略
+ }
+ }
+ return ['ok' => true, 'data' => $out];
+ }
+
+ /** DCIM 专用操作(rescue/bmc/cancel_task/reinstall/crack_pass 等)。 */
+ public static function hostDcimAction($supplier, $upHostId, $action, $extra = [])
+ {
+ $client = self::client($supplier);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $client->dcimAction($action, (int)$upHostId, $extra);
+ if (($res['status'] ?? 0) == 200) {
+ return ['ok' => true, 'msg' => (string)($res['msg'] ?? '操作成功')];
+ }
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '操作失败')];
+ } catch (CubeFinanceException $e) {
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
+
+ /** 重装可选系统列表(GET host/dedicatedserver),按上游主机 ID 获取。 */
+ public static function hostDedicatedOs($supplier, $upHostId)
+ {
+ $client = self::client($supplier, 8);
+ if (!$client) {
+ return ['ok' => false, 'msg' => '供应商连接信息不完整'];
+ }
+ try {
+ $res = $client->hostDedicatedServer((int)$upHostId);
+ if (($res['status'] ?? 0) != 200) {
+ error_log('[zjmfmanager_reserve] hostDedicatedServer failed: '
+ . 'host_id=' . (int)$upHostId . ' status=' . ($res['status'] ?? '')
+ . ' msg=' . ($res['msg'] ?? ''));
+ return ['ok' => false, 'msg' => (string)($res['msg'] ?? '获取系统列表失败')];
+ }
+ $data = $res['data'] ?? [];
+ return [
+ 'ok' => true,
+ 'os_list' => is_array($data['cloud_os'] ?? null) ? $data['cloud_os'] : [],
+ 'groups' => is_array($data['cloud_os_group'] ?? null) ? $data['cloud_os_group'] : [],
+ ];
+ } catch (CubeFinanceException $e) {
+ error_log('[zjmfmanager_reserve] hostDedicatedServer exception: ' . $e->getMessage());
+ return ['ok' => false, 'msg' => $e->getMessage()];
+ }
+ }
/** 只读获取升级选项(配置项或可升降级产品列表)。 */
public static function upgradeOptions($supplier, $upHostId, $kind)
@@ -637,13 +921,57 @@ class ZjmfUpstream
* 响应解析辅助(防御式,字段以实际上游返回为准)
* ============================================================ */
- /** 从响应 data 中提取金额(分),常见字段/嵌套结构兜底。 */
+ /**
+ * 购物车算价:优先官方价格计算接口 POST /cart/get_total,
+ * 失败回退配置页 GET /cart/set_config。
+ */
+ protected static function trialPrice($client, $upId, $cycle)
+ {
+ $bc = strtolower($cycle);
+ try {
+ $price = self::parseTrialPrice($client->cartGetTotal([
+ 'pid' => $upId,
+ 'billingcycle' => $bc,
+ 'qty' => 1,
+ ]));
+ if ($price > 0) {
+ return $price;
+ }
+ } catch (CubeFinanceException $e) {
+ // 忽略,走回退
+ }
+ try {
+ return self::parseTrialPrice($client->cartSetConfig([
+ 'pid' => $upId,
+ 'billingcycle' => $bc,
+ ]));
+ } catch (CubeFinanceException $e) {
+ return 0;
+ }
+ }
+
protected static function parseTrialPrice($res)
{
$data = $res['data'] ?? [];
if (!is_array($data)) {
return 0;
}
+ // 官方 get_total 响应:data.products[](signal_price 为单个产品周期费用)
+ if (isset($data['products']) && is_array($data['products'])) {
+ foreach ($data['products'] as $p) {
+ if (!is_array($p)) {
+ continue;
+ }
+ foreach (['signal_price', 'product_price', 'price_total', 'total', 'amount', 'subtotal'] as $k) {
+ if (isset($p[$k]) && $p[$k] !== '' && $p[$k] !== null) {
+ $c = self::toCents($p[$k]);
+ if ($c > 0) {
+ return $c;
+ }
+ }
+ }
+ }
+ }
foreach (['price', 'money', 'total', 'amount', 'subtotal', 'renewal_price'] as $k) {
if (isset($data[$k]) && $data[$k] !== '' && $data[$k] !== null) {
$c = self::toCents($data[$k]);
@@ -678,16 +1006,36 @@ class ZjmfUpstream
}
/**
- * 从列表项提取各周期价格(分)。
- * 魔方财务列表项为「单周期」结构:billingcycle + price + billingcycle_zh(cycle 只是标签字符串)。
- * 兼容完整周期结构(cycle/pricing 为数组或映射),解析失败返回空数组。
+ * 从列表行提取各周期价格(分)。
+ * 兼容三种结构(优先完整结构,避免单周期字段提前返回漏掉其余周期):
+ * a. 完整周期:cycle/pricing 为数组或映射
+ * b. 并行数组:billingcycle[] + billingcycle_price[](+ billingcycle_zh[])
+ * c. 单周期:billingcycle + price + billingcycle_zh(仅作最后兜底)
+ * 解析失败返回空数组。
*/
protected static function parseItemCycles($item)
{
if (!is_array($item)) {
return [];
}
- // 单周期结构:billingcycle + price + billingcycle_zh
+ // a. 完整周期结构:cycle/pricing 为数组或映射
+ $raw = $item['cycle'] ?? $item['pricing'] ?? null;
+ if (is_string($raw)) {
+ $decoded = json_decode($raw, true);
+ $raw = is_array($decoded) ? $decoded : null;
+ }
+ if (is_array($raw) && $raw !== []) {
+ $out = self::parseCycleMap($raw);
+ if ($out !== []) {
+ return $out;
+ }
+ }
+ // b. 并行数组结构:billingcycle[] + billingcycle_price[](v10 列表常见)
+ $out = self::parseParallelCycles($item);
+ if ($out !== []) {
+ return $out;
+ }
+ // c. 单周期结构:billingcycle + price + billingcycle_zh
$key = (string)($item['billingcycle'] ?? '');
if ($key !== '') {
$cents = self::toCents(self::pickPrice($item));
@@ -696,16 +1044,41 @@ class ZjmfUpstream
return [$entry['cycle'] => $entry];
}
}
- // 兼容完整周期结构:cycle/pricing 为数组或映射
- $raw = $item['cycle'] ?? $item['pricing'] ?? null;
- if (is_string($raw)) {
- $decoded = json_decode($raw, true);
- $raw = is_array($decoded) ? $decoded : null;
+ return [];
+ }
+
+ /**
+ * 并行数组结构:billingcycle[] + billingcycle_price[](+ billingcycle_zh[])。
+ * 若任一数组缺失或长度不一致,返回空数组。
+ */
+ protected static function parseParallelCycles($item)
+ {
+ $keys = $item['billingcycle'] ?? null;
+ $prices = $item['billingcycle_price'] ?? $item['billingcycleprices'] ?? null;
+ if (!is_array($keys) || $keys === [] || !is_array($prices)) {
+ return [];
}
- if (is_array($raw) && $raw !== []) {
- return self::parseCycleMap($raw);
+ $names = $item['billingcycle_zh'] ?? null;
+ if (!is_array($names)) {
+ $names = null;
}
- return [];
+ $out = [];
+ foreach ($keys as $i => $k) {
+ $k = (string)$k;
+ if ($k === '' || !isset($prices[$i])) {
+ continue;
+ }
+ $cents = self::toCents($prices[$i]);
+ if ($cents <= 0) {
+ continue;
+ }
+ $name = $names ? (string)($names[$i] ?? '') : '';
+ $entry = self::cycleEntry($k, $cents, $name);
+ if (!isset($out[$entry['cycle']])) {
+ $out[$entry['cycle']] = $entry;
+ }
+ }
+ return $out;
}
/**
@@ -719,6 +1092,10 @@ class ZjmfUpstream
return [];
}
$prod = is_array($data['product'] ?? null) ? $data['product'] : $data;
+ $out = self::parseParallelCycles($prod);
+ if ($out !== []) {
+ return $out;
+ }
$raw = $prod['cycle'] ?? $data['cycle'] ?? null;
if (is_string($raw)) {
$decoded = json_decode($raw, true);
@@ -730,6 +1107,449 @@ class ZjmfUpstream
return self::parseCycleMap($raw);
}
+ /* ============================================================
+ * 周期键探测(add_to_shop 失败时定位上游真实 billingcycle 键)
+ * ============================================================ */
+
+ /**
+ * 探测上游商品可选周期(真实 billingcycle 键 + 中文名 + 价格)。
+ * 优先 GET /cart/get_product_config(含该产品可选周期数据),
+ * 失败回退商品详情。返回 [billingcycle键 => ['name'=>, 'price_cents'=>]];
+ * 无结果返回 []。
+ *
+ * @param CubeFinanceClient $client
+ * @param int $upId
+ * @param array|null $detail 已抓取的详情响应(避免重复请求)
+ */
+ protected static function probeCycles($client, $upId, $detail = null)
+ {
+ $out = [];
+ try {
+ $res = $client->cartGetProductConfig((int)$upId);
+ if (self::respOk($res)) {
+ $out = self::parseConfigCycles($res);
+ }
+ } catch (CubeFinanceException $e) {
+ $out = [];
+ }
+ if ($out !== []) {
+ return $out;
+ }
+ if ($detail === null) {
+ try {
+ $detail = $client->productDetail((int)$upId, 'agent');
+ } catch (CubeFinanceException $e) {
+ return [];
+ }
+ }
+ return self::parseDetailCycleList($detail);
+ }
+
+ /**
+ * 解析购物车配置接口响应中的周期列表。
+ * 兼容 data.products(数组或单产品)与 data.product 两种外壳;
+ * 周期可来自 cycle(JSON 串/数组/映射)、pricing 映射、并行数组。
+ */
+ protected static function parseConfigCycles($res)
+ {
+ $data = $res['data'] ?? [];
+ if (!is_array($data)) {
+ return [];
+ }
+ $products = $data['products'] ?? $data['product'] ?? null;
+ if (!is_array($products)) {
+ return [];
+ }
+ if (isset($products['cycle']) || isset($products['pricing'])
+ || isset($products['billingcycle']) || isset($products['id'])) {
+ $products = [$products];
+ }
+ $out = [];
+ foreach ($products as $prod) {
+ if (!is_array($prod)) {
+ continue;
+ }
+ foreach (self::cycleSourceCandidates($prod) as $item) {
+ $key = (string)($item['billingcycle'] ?? '');
+ if ($key === '') {
+ continue;
+ }
+ $cents = self::toCents(
+ $item['price'] ?? $item['product_price'] ?? $item['price_total'] ?? 0
+ );
+ $name = (string)($item['billingcycle_zh'] ?? '');
+ if (!isset($out[$key])) {
+ $out[$key] = ['name' => $name, 'price_cents' => max(0, $cents)];
+ } elseif ($cents > 0 && $out[$key]['price_cents'] <= 0) {
+ $out[$key]['price_cents'] = max(0, $cents);
+ }
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * 从单个产品数据提取周期条目数组 [{billingcycle, billingcycle_zh, price}]。
+ * 优先完整周期(cycle/pricing),其次并行数组(billingcycle[]+billingcycle_price[])。
+ */
+ protected static function cycleSourceCandidates($prod)
+ {
+ $items = [];
+ $raw = $prod['cycle'] ?? $prod['pricing'] ?? null;
+ if (is_string($raw)) {
+ $decoded = json_decode($raw, true);
+ $raw = is_array($decoded) ? $decoded : null;
+ }
+ if (is_array($raw)) {
+ if (isset($raw[0]) && is_array($raw[0])) {
+ // 数组形式:[{billingcycle, price, billingcycle_zh}]
+ foreach ($raw as $it) {
+ if (is_array($it) && (string)($it['billingcycle'] ?? '') !== '') {
+ $items[] = $it;
+ }
+ }
+ } else {
+ // 映射形式:{cycle: 价格 或 {price:...}}
+ foreach ($raw as $k => $v) {
+ $items[] = is_array($v)
+ ? array_merge($v, ['billingcycle' => (string)$k])
+ : ['billingcycle' => (string)$k, 'price' => $v];
+ }
+ }
+ }
+ if ($items !== []) {
+ return $items;
+ }
+ // 并行数组形式
+ $keys = $prod['billingcycle'] ?? null;
+ $prices = $prod['billingcycle_price'] ?? $prod['billingcycleprices'] ?? null;
+ if (is_array($keys) && is_array($prices)) {
+ $names = is_array($prod['billingcycle_zh'] ?? null) ? $prod['billingcycle_zh'] : null;
+ foreach ($keys as $i => $k) {
+ $items[] = [
+ 'billingcycle' => (string)$k,
+ 'billingcycle_zh' => $names ? (string)($names[$i] ?? '') : '',
+ 'price' => $prices[$i] ?? 0,
+ ];
+ }
+ }
+ return $items;
+ }
+
+ /** 详情响应 → [billingcycle键 => ['name'=>, 'price_cents'=>]]。 */
+ protected static function parseDetailCycleList($detail)
+ {
+ $out = [];
+ foreach (self::parseDetailCycles($detail) as $entry) {
+ $key = (string)($entry['up_cycle'] ?? '');
+ if ($key === '') {
+ continue;
+ }
+ $out[$key] = [
+ 'name' => (string)($entry['name'] ?? ''),
+ 'price_cents' => (int)($entry['agent_price_cents'] ?? 0),
+ ];
+ }
+ return $out;
+ }
+
+ /* ============================================================
+ * 配置项默认值探测(add_to_shop 需要 currencyid + configoption)
+ * ============================================================ */
+
+ /**
+ * 探测商品默认配置(货币ID + 配置项默认选择),用于 add_to_shop 计价。
+ * 优先 GET /cart/get_product_config,失败回退商品详情。
+ * 返回 ['currencyid'=>int, 'configoption'=>[optionId=>子项ID或数量]];
+ * 探测不到 configoption 时至少带回 currencyid。
+ *
+ * @param CubeFinanceClient $client
+ * @param int $upId
+ * @param array|null $detail 已抓取的详情响应(避免重复请求)
+ */
+ protected static function probeConfigDefaults($client, $upId, $detail = null)
+ {
+ try {
+ $res = $client->cartGetProductConfig((int)$upId);
+ if (self::respOk($res)) {
+ $out = self::parseConfigDefaults($res);
+ if (!empty($out['configoption'])) {
+ return $out;
+ }
+ }
+ } catch (CubeFinanceException $e) {
+ // 忽略,走详情回退
+ }
+ if ($detail === null) {
+ try {
+ $detail = $client->productDetail((int)$upId, 'agent');
+ } catch (CubeFinanceException $e) {
+ return ['currencyid' => 0, 'configoption' => []];
+ }
+ }
+ return self::parseConfigDefaults($detail);
+ }
+
+ /**
+ * 从响应中解析 currencyid 与 configoption 默认选择。
+ * 兼容 data.products(数组/单产品)与 data.product 外壳;
+ * 配置项结构兼容数组组/映射组/扁平默认值/数量型多种形态。
+ */
+ protected static function parseConfigDefaults($res)
+ {
+ $data = $res['data'] ?? [];
+ if (!is_array($data)) {
+ return ['currencyid' => 0, 'configoption' => []];
+ }
+ $out = ['currencyid' => 0, 'configoption' => []];
+ if (isset($data['default_currency']) && is_numeric($data['default_currency'])) {
+ $out['currencyid'] = (int)$data['default_currency'];
+ }
+ $currencies = $data['currencies'] ?? null;
+ if (is_array($currencies)) {
+ foreach ($currencies as $c) {
+ if (is_array($c) && isset($c['id']) && is_numeric($c['id'])) {
+ if ($out['currencyid'] <= 0) {
+ $out['currencyid'] = (int)$c['id'];
+ }
+ break;
+ }
+ }
+ }
+ $products = $data['products'] ?? $data['product'] ?? null;
+ if (!is_array($products)) {
+ if ($out['currencyid'] <= 0) {
+ $out['currencyid'] = 1;
+ }
+ return $out;
+ }
+ if (isset($products['id']) || isset($products['pid'])
+ || isset($products['configoption']) || isset($products['config_option'])) {
+ $products = [$products];
+ }
+ $groups = [];
+ foreach ($products as $prod) {
+ if (!is_array($prod)) {
+ continue;
+ }
+ // 产品级 currencyid(映射形式取首个键)
+ if ($out['currencyid'] <= 0 && isset($prod['currencyid']) && is_array($prod['currencyid'])) {
+ foreach ($prod['currencyid'] as $cid => $cfg) {
+ if (is_numeric($cid) && (int)$cid > 0) {
+ $out['currencyid'] = (int)$cid;
+ break;
+ }
+ }
+ }
+ $co = $prod['configoption'] ?? $prod['config_option'] ?? null;
+ if (is_array($co)) {
+ $groups = array_merge($groups, self::normalizeConfigGroups($co));
+ }
+ }
+ foreach ($groups as $g) {
+ if (!is_array($g)) {
+ continue;
+ }
+ $oid = (int)($g['id'] ?? $g['configoption_id'] ?? 0);
+ if ($oid <= 0) {
+ continue;
+ }
+ $val = self::pickConfigDefault($g);
+ if ($val !== null && $val !== '') {
+ $out['configoption'][(string)$oid] = (string)$val;
+ }
+ }
+ if ($out['currencyid'] <= 0) {
+ $out['currencyid'] = 1; // 兜底默认货币
+ }
+ return $out;
+ }
+
+ /** 将 configoption 数据规范化为配置组列表。 */
+ protected static function normalizeConfigGroups($co)
+ {
+ $groups = [];
+ if (isset($co[0]) && is_array($co[0])) {
+ return $co; // 数组组形式
+ }
+ foreach ($co as $k => $v) {
+ if (!is_array($v)) {
+ // 扁平:optionId => 默认值
+ $groups[] = ['id' => is_numeric($k) ? $k : 0, '_flat_value' => $v];
+ continue;
+ }
+ if (isset($v['option']) || isset($v['options']) || isset($v['id']) || isset($v['configoption_id'])) {
+ if (!isset($v['id']) && !isset($v['configoption_id']) && is_numeric($k)) {
+ $v['id'] = (int)$k;
+ }
+ $groups[] = $v;
+ continue;
+ }
+ // 映射:optionId => 子项数组
+ $groups[] = ['id' => is_numeric($k) ? (int)$k : 0, 'option' => $v];
+ }
+ return $groups;
+ }
+
+ /**
+ * 从单个配置组取默认子项值(子项ID或数量)。
+ * 优先 checked/selected/default 标记,否则取第一个子项。
+ */
+ protected static function pickConfigDefault($g)
+ {
+ if (array_key_exists('_flat_value', $g)) {
+ return (string)$g['_flat_value'];
+ }
+ $type = strtolower((string)($g['type'] ?? ''));
+ if (in_array($type, ['qty', 'quantity', 'number'], true)) {
+ $q = $g['qty'] ?? $g['quantity'] ?? $g['value'] ?? 1;
+ return (string)$q;
+ }
+ $subs = $g['option'] ?? $g['options'] ?? $g['sub_option']
+ ?? $g['suboption'] ?? $g['child'] ?? $g['values'] ?? null;
+ if (!is_array($subs)) {
+ return null;
+ }
+ $first = null;
+ foreach ($subs as $s) {
+ if (!is_array($s)) {
+ continue;
+ }
+ if ($first === null) {
+ $first = $s;
+ }
+ foreach (['checked', 'selected', 'default', 'is_check', 'is_default'] as $f) {
+ if (isset($s[$f]) && ((int)$s[$f] === 1 || (string)$s[$f] === '1')) {
+ $first = $s;
+ break 2;
+ }
+ }
+ }
+ if ($first === null) {
+ return null;
+ }
+ $id = $first['id'] ?? $first['suboption_id'] ?? $first['value'] ?? null;
+ return $id === null ? null : (string)$id;
+ }
+
+ /** 上游失败信息是否与周期/价格相关(命中才触发探测重试)。 */
+ protected static function isCyclePriceError($res)
+ {
+ $msg = (string)($res['msg'] ?? '');
+ return $msg !== '' && (
+ strpos($msg, '未配置价格') !== false
+ || strpos($msg, '价格错误') !== false
+ || strpos($msg, '周期') !== false
+ || stripos($msg, 'cycle') !== false
+ );
+ }
+
+ /**
+ * 本地周期键 → 上游真实 billingcycle 键。
+ * 忽略大小写并按别名(monthly/month、quarterly/quarter 等)匹配,
+ * 也匹配中文名(月付/月/季付…);找不到返回原键。
+ */
+ protected static function matchUpCycle($localCycle, array $available)
+ {
+ $localCycle = (string)$localCycle;
+ if ($localCycle === '' || $available === []) {
+ return $localCycle;
+ }
+ $aliases = self::cycleAliases($localCycle);
+ foreach ($available as $key => $cfg) {
+ $candidate = (string)$key;
+ if (in_array(self::normKey($candidate), $aliases, true)) {
+ return $candidate;
+ }
+ $name = (string)($cfg['name'] ?? '');
+ if ($name !== '' && in_array(self::normKey($name), $aliases, true)) {
+ return $candidate;
+ }
+ }
+ $normLocal = self::normKey($localCycle);
+ foreach ($available as $key => $cfg) {
+ if (self::normKey((string)$key) === $normLocal) {
+ return (string)$key;
+ }
+ }
+ return $localCycle;
+ }
+
+ /** 本地周期键的别名集合(归一化后,含中文名)。 */
+ protected static function cycleAliases($localCycle)
+ {
+ $norm = self::normKey($localCycle);
+ $map = [
+ 'monthly' => ['monthly', 'month', 'm', '月', '月付'],
+ 'quarterly' => ['quarterly', 'quarter', 'q', '季', '季付'],
+ 'semiannually' => ['semiannually', 'semiannual', 'halfyear', 'half_year', 'half', 'semi', '半年', '半年付'],
+ 'annually' => ['annually', 'annual', 'yearly', 'year', 'y', '年', '年付'],
+ 'biennially' => ['biennially', 'biennial', 'biennium', 'twoyear', 'two_year', '两年', '两年付'],
+ 'triennially' => ['triennially', 'triennial', 'triennium', 'threeyear', 'three_year', '三年', '三年付'],
+ ];
+ foreach ($map as $k => $list) {
+ if ($norm === $k || in_array($norm, $list, true)) {
+ return $list;
+ }
+ }
+ return [$norm];
+ }
+
+ /** 键归一化:小写 + 去非字母数字(保留中文)。 */
+ protected static function normKey($key)
+ {
+ return strtolower(preg_replace('/[^a-z0-9\x{4e00}-\x{9fa5}]/iu', '', (string)$key));
+ }
+
+ /** 可用周期列表渲染为可读文案(供失败信息展示)。 */
+ protected static function formatAvailable(array $avail)
+ {
+ $parts = [];
+ foreach ($avail as $key => $cfg) {
+ $name = (string)($cfg['name'] ?? '');
+ $parts[] = $name !== '' ? $key . '(' . $name . ')' : $key;
+ }
+ return $parts === [] ? '' : ';上游可用周期:' . implode('、', $parts);
+ }
+
+ /**
+ * 将本地周期对应的上游键写回商品 cycles JSON(后续购买直接使用)。
+ */
+ protected static function persistUpCycle($supplierId, $upProductId, $localCycle, $upCycle)
+ {
+ global $DB;
+ $product = zjmf_product_get_by_up((int)$supplierId, (int)$upProductId);
+ if (!$product) {
+ return;
+ }
+ $cycles = json_decode((string)($product['cycles'] ?? ''), true);
+ if (!is_array($cycles)) {
+ return;
+ }
+ $changed = false;
+ foreach ($cycles as &$cfg) {
+ if (is_array($cfg) && (string)($cfg['cycle'] ?? '') === (string)$localCycle) {
+ if ((string)($cfg['up_cycle'] ?? '') !== (string)$upCycle) {
+ $cfg['up_cycle'] = (string)$upCycle;
+ $changed = true;
+ }
+ break;
+ }
+ }
+ unset($cfg);
+ if ($changed) {
+ $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_product SET cycles=?, updated_at=?
+ WHERE id=?",
+ [
+ json_encode($cycles, JSON_UNESCAPED_UNICODE),
+ date('Y-m-d H:i:s'),
+ (int)$product['id'],
+ ]
+ );
+ }
+ }
+
/** 构建单个周期条目(键归一化 + 名称兜底)。 */
protected static function cycleEntry($key, $cents, $name = '')
{
@@ -737,6 +1557,8 @@ class ZjmfUpstream
$known = zjmf_cycles();
return [
'cycle' => $norm,
+ // 上游原始 billingcycle 值(如 monthly),购买时原样回传
+ 'up_cycle' => (string)$key,
'name' => $name !== '' ? $name : ($known[$norm]['name'] ?? $norm),
'agent_price_cents' => max(0, (int)$cents),
];
@@ -829,6 +1651,102 @@ class ZjmfUpstream
return (string)($item['type'] ?? $item['module'] ?? $item['module_name'] ?? '');
}
+ /**
+ * 从购物车数据中定位刚添加产品的购物车位置。
+ * 部分版本 add_to_shop 不返回位置 i,需 GET /cart/get_shop_data 后
+ * 按 productid 匹配(购物车数组的键/序号即位置 i;同商品取最后一项,即刚添加的)。
+ *
+ * @param CubeFinanceClient $client
+ * @param int $upProductId
+ * @param string $upCycle 上游 billingcycle 键
+ * @return int 位置,找不到返回 -1
+ */
+ protected static function cartPosition($client, $upProductId, $upCycle)
+ {
+ try {
+ $res = $client->cartGetShopData();
+ } catch (CubeFinanceException $e) {
+ return -1;
+ }
+ if (!self::respOk($res)) {
+ return -1;
+ }
+ $products = $res['data']['cart_products'] ?? null;
+ if (!is_array($products)) {
+ return -1;
+ }
+ $wantPid = (string)$upProductId;
+ $wantCycle = strtolower((string)$upCycle);
+ $lastPid = -1;
+ $lastMatch = -1;
+ foreach ($products as $i => $p) {
+ if (!is_array($p)) {
+ continue;
+ }
+ $pid = (string)($p['productid'] ?? $p['pid'] ?? $p['id'] ?? '');
+ if ($pid !== $wantPid) {
+ continue;
+ }
+ $lastPid = (int)$i;
+ $cycle = strtolower((string)($p['billingcycle'] ?? ''));
+ if ($cycle === $wantCycle || $cycle === '') {
+ $lastMatch = (int)$i; // 周期匹配(或未知)的最后一个即刚添加项
+ }
+ }
+ if ($lastMatch >= 0) {
+ return $lastMatch;
+ }
+ return $lastPid; // 仅 pid 匹配的最后一个
+ }
+
+ /**
+ * 从加购响应中取购物车位置 data.i(兼容字符串 data、其他位置键、一层嵌套)。
+ *
+ * @param array $res 响应数组
+ * @return int 位置,找不到返回 -1
+ */
+ protected static function findPosition($res)
+ {
+ $d = $res['data'] ?? null;
+ if (is_string($d)) {
+ if (is_numeric($d)) {
+ return (int)$d;
+ }
+ $decoded = json_decode($d, true);
+ if (is_array($decoded)) {
+ $d = $decoded;
+ }
+ }
+ if (is_numeric($d)) {
+ return (int)$d;
+ }
+ if (!is_array($d)) {
+ return -1;
+ }
+ foreach (['i', 'position', 'pos', 'cart_i', 'key', 'id'] as $k) {
+ if (!array_key_exists($k, $d)) {
+ continue;
+ }
+ $v = $d[$k];
+ if (is_array($v)) {
+ $v = reset($v);
+ }
+ if (is_numeric($v)) {
+ return (int)$v;
+ }
+ }
+ // 一层嵌套深搜(如 data.products[].i)
+ foreach ($d as $v) {
+ if (is_array($v)) {
+ $sub = self::findPosition(['data' => $v]);
+ if ($sub >= 0) {
+ return $sub;
+ }
+ }
+ }
+ return -1;
+ }
+
/** 上游返回是否成功(200 常规成功;1001 购买成功/支付完成,无需继续支付)。 */
protected static function respOk($res)
{
@@ -915,6 +1833,26 @@ class ZjmfUpstream
return 0;
}
+ /**
+ * 插件周期键 → 上游 billingcycle 值。
+ * 优先取同步时保留的上游原始值(up_cycle),未同步/旧数据回退小写(官方周期如 monthly、day、hour)。
+ */
+ protected static function upstreamCycle($supplierId, $upProductId, $cycle)
+ {
+ $cycle = (string)$cycle;
+ if ($cycle !== '') {
+ $product = zjmf_product_get_by_up((int)$supplierId, (int)$upProductId);
+ if ($product) {
+ $cycles = zjmf_product_cycles($product);
+ $entry = $cycles[$cycle] ?? null;
+ if (is_array($entry) && (string)($entry['up_cycle'] ?? '') !== '') {
+ return (string)$entry['up_cycle'];
+ }
+ }
+ }
+ return strtolower($cycle);
+ }
+
/**
* 查询账单详情并轮询主机 ID(主机创建可能异步)。
* 官方:GET /invoices/{id} → data.invoices[].status(支付状态)+
@@ -979,13 +1917,13 @@ class ZjmfUpstream
return in_array($st, ['paid', '已支付', 'completed', 'complete', 'success', 'payment'], true);
}
- /** 从主机详情响应提取主机数据(兼容 host_data/host 键与单对象/数组结构)。 */
+ /** 从主机详情响应提取主机数据(兼容 host_data/host/info/list/data 键与单对象/数组结构)。 */
protected static function pickHostData($data)
{
if (!is_array($data)) {
return [];
}
- foreach (['host_data', 'host', 'info'] as $k) {
+ foreach (['host_data', 'host', 'info', 'list', 'data'] as $k) {
if (isset($data[$k]) && is_array($data[$k])) {
$v = $data[$k];
if (isset($v[0]) && is_array($v[0])) {
@@ -1007,31 +1945,43 @@ class ZjmfUpstream
'username' => (string)($host['username'] ?? ''),
'password' => (string)($host['password'] ?? ''),
'name' => (string)($host['productname'] ?? $host['name'] ?? ''),
- 'renew_date' => (string)($host['nextduedate'] ?? $host['renew_date'] ?? $host['renewdate'] ?? ''),
+ // 上游部分版本 nextduedate 为 Unix 时间戳,统一归一化为 Y-m-d
+ 'renew_date' => zjmf_normalize_date(
+ (string)($host['nextduedate'] ?? $host['renew_date'] ?? $host['renewdate'] ?? '')
+ ),
];
} catch (CubeFinanceException $e) {
return ['username' => '', 'password' => '', 'name' => '', 'renew_date' => ''];
}
}
- /** 上游主机状态 → 本地展示状态(active/suspend/unknown)。 */
+ /** 上游主机状态 → 本地展示状态(active/suspend/pending/terminated/unknown)。 */
public static function mapHostStatus($data)
{
if (!is_array($data)) {
return 'unknown';
}
+ $st = strtolower(trim((string)($data['status'] ?? $data['domainstatus'] ?? '')));
+ if ($st !== '') {
+ if (in_array($st, ['active', 'on', 'true', 'completed', '运行中'], true)) {
+ return 'active';
+ }
+ if (in_array($st, ['pending', 'wait', 'waiting', '待开通'], true)) {
+ return 'pending';
+ }
+ if (in_array($st, ['suspended', 'suspend', 'paused', 'off', '已暂停'], true)) {
+ return 'suspend';
+ }
+ if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'fraud', '已终止'], true)) {
+ return 'terminated';
+ }
+ return 'unknown';
+ }
+ // 无状态字段时用 qk 兜底(false 视为不可用)
$qk = $data['qk'] ?? null;
if ($qk !== null && in_array((string)$qk, ['false', '0', ''], true)) {
return 'suspend';
}
- $st = (string)($data['status'] ?? $data['domainstatus'] ?? '');
- $st = strtolower($st);
- if (in_array($st, ['active', 'on', 'true', '运行中'], true)) {
- return 'active';
- }
- if (in_array($st, ['suspended', 'suspend', 'paused', 'off'], true)) {
- return 'suspend';
- }
return 'unknown';
}
}
diff --git a/app_plugins/zjmfmanager_reserve/lib/zjmf.php b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
index 1694c7b..376525c 100644
--- a/app_plugins/zjmfmanager_reserve/lib/zjmf.php
+++ b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
@@ -134,21 +134,24 @@ function zjmf_encrypt($plain)
return authcode((string)$plain, 'ENCODE', SYS_KEY);
}
-/** 密文解密。 */
+/**
+ * 密文解密(防御式)。
+ * authcode 解密分支在 PHP 8 下对乱码密文会执行「前10位 - time()」并抛
+ * TypeError(Unsupported operand types: string - int),导致详情页 500。
+ * 这里对空/过短密文直接返回,异常兜底为空串。
+ */
function zjmf_decrypt($cipher)
{
- return authcode((string)$cipher, 'DECODE', SYS_KEY);
-}
-
-/** 账号脱敏展示(保留前 3 后 2)。 */
-function zjmf_mask_account($username)
-{
- $s = (string)$username;
- $len = strlen($s);
- if ($len <= 5) {
- return substr($s, 0, 1) . '***';
+ $cipher = (string)$cipher;
+ if ($cipher === '' || strlen($cipher) <= 4) {
+ return ''; // 未设置密码或非 authcode 密文
+ }
+ try {
+ $out = authcode($cipher, 'DECODE', SYS_KEY);
+ return is_string($out) ? $out : '';
+ } catch (Throwable $e) {
+ return '';
}
- return substr($s, 0, 3) . '***' . substr($s, -2);
}
/** 写操作日志。 */
@@ -363,6 +366,7 @@ function zjmf_product_cycles($product)
$cycle = (string)$raw[0];
$map[$cycle] = [
'cycle' => $cycle,
+ 'up_cycle' => '',
'name' => (string)$raw[1],
'price_cents' => (int)$raw[2],
'agent_price_cents' => (int)$raw[2],
@@ -378,6 +382,7 @@ function zjmf_product_cycles($product)
}
$map[$cycle] = [
'cycle' => $cycle,
+ 'up_cycle' => (string)($item['up_cycle'] ?? ''),
'name' => (string)($item['name'] ?? $cycle),
'price_cents' => (int)($item['price_cents'] ?? 0),
'agent_price_cents' => (int)($item['agent_price_cents'] ?? 0),
@@ -721,7 +726,9 @@ function zjmf_host_get_by_user($user_id, $host_id)
{
global $DB;
return $DB->get_row_prepare(
- "SELECT h.* FROM MN_plugin_zjmf_host h
+ "SELECT h.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_host h
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = h.supplier_id
WHERE h.id=? AND h.user_id=? LIMIT 1",
[(int)$host_id, (int)$user_id]
) ?: null;
@@ -737,12 +744,14 @@ function zjmf_host_get_by_up($up_host_id)
) ?: null;
}
-/** 用户主机列表。 */
+/** 用户主机列表(含供应商名)。 */
function zjmf_host_list_by_user($user_id)
{
global $DB;
return $DB->get_all_prepare(
- "SELECT h.* FROM MN_plugin_zjmf_host h
+ "SELECT h.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_host h
+ LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = h.supplier_id
WHERE h.user_id=? ORDER BY h.id DESC",
[(int)$user_id]
) ?: [];
@@ -829,6 +838,151 @@ function zjmf_host_update_cache($host_id, $data)
);
}
+/**
+ * 归一化上游返回的日期/时间戳为 Y-m-d H:i:s(精确到秒,与 created_at 展示一致)。
+ * 魔方财务部分版本接口返回 Unix 时间戳(秒/毫秒),部分返回 Y-m-d / Y-m-d H:i:s;
+ * 已含时分秒的标准串原样保留。
+ *
+ * @param mixed $val
+ * @return string
+ */
+function zjmf_normalize_date($val)
+{
+ $s = trim((string)$val);
+ if ($s === '') {
+ return '';
+ }
+ // 已是标准日期(时间)串:截断到秒粒度并保留
+ if (preg_match('/^\d{4}-\d{2}-\d{2}/', $s)) {
+ $s = preg_replace(
+ '/^(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?).*$/',
+ '$1',
+ $s
+ );
+ return $s;
+ }
+ if (preg_match('/^\d+$/', $s)) {
+ $t = (int)$s;
+ if ($t > 100000000000) {
+ $t = (int)($t / 1000); // 毫秒时间戳
+ }
+ return $t > 0 ? date('Y-m-d H:i:s', $t) : '';
+ }
+ $t = strtotime($s);
+ return $t ? date('Y-m-d H:i:s', $t) : $s;
+}
+
+/**
+ * 补齐本地主机缺失的上游主机 ID(up_host_id<=0 时)。
+ *
+ * 开通/结算响应未能解析出主机 ID 时会落库 up_host_id=0,导致用户端
+ * 卡片按钮不可用、详情页无法拉取实时信息。此函数通过上游
+ * GET host/list(我的主机列表)按 产品名 + 开通日期 匹配回填。
+ * 同一次请求内只拉取一次上游列表(进程内静态缓存)。
+ *
+ * @param array $host MN_plugin_zjmf_host 行
+ * @return array 回填后的主机行(未匹配则原样返回)
+ */
+function zjmf_backfill_host_upid($host)
+{
+ global $DB, $date;
+ if (!is_array($host) || (int)($host['id'] ?? 0) <= 0 || (int)($host['up_host_id'] ?? 0) > 0) {
+ return $host;
+ }
+ $supplierId = (int)($host['supplier_id'] ?? 0);
+ if ($supplierId <= 0) {
+ return $host;
+ }
+ $supplier = zjmf_supplier_get($supplierId);
+ if (!$supplier || (int)$supplier['status'] !== 1) {
+ return $host;
+ }
+ // 进程内缓存:同一次请求(列表页/详情页)只向上游请求一次
+ static $cache = [];
+ if (!array_key_exists($supplierId, $cache)) {
+ $res = ZjmfUpstream::hostList($supplier, ['orderby' => 'id', 'sort' => 'DESC']);
+ $cache[$supplierId] = empty($res['ok']) ? [] : ($res['data']['list'] ?? []);
+ }
+ $list = $cache[$supplierId];
+ if (!is_array($list) || $list === []) {
+ return $host;
+ }
+ $name = (string)($host['name'] ?? '');
+ $created = substr((string)($host['created_at'] ?? ''), 0, 10);
+ $candidates = [];
+ foreach ($list as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $pn = (string)($item['productname'] ?? '');
+ if ($pn === '' || ($name !== '' && $pn !== $name
+ && strpos($pn, $name) !== 0 && strpos($name, $pn) !== 0)) {
+ continue;
+ }
+ $candidates[] = $item;
+ }
+ if ($candidates === []) {
+ return $host;
+ }
+ // 多台同名主机时,按开通日期与本地创建日期最接近者匹配
+ $best = $candidates[0];
+ if ($created !== '') {
+ $bestDiff = PHP_INT_MAX;
+ $bestTime = strtotime($created) ?: 0;
+ foreach ($candidates as $item) {
+ // regdate 部分版本为 Unix 时间戳,先归一化为 Y-m-d 再比较
+ $rd = zjmf_normalize_date((string)($item['regdate'] ?? ''));
+ if ($rd === '') {
+ continue;
+ }
+ $rdTime = strtotime($rd) ?: 0;
+ $diff = $rdTime ? abs($rdTime - $bestTime) : PHP_INT_MAX;
+ if ($diff < $bestDiff) {
+ $bestDiff = $diff;
+ $best = $item;
+ }
+ }
+ }
+ $upId = (int)($best['id'] ?? 0);
+ if ($upId <= 0) {
+ return $host;
+ }
+ $status = function_exists('zjmf_map_upstream_status')
+ ? zjmf_map_upstream_status((string)($best['domainstatus'] ?? ''))
+ : (string)($host['status'] ?? '');
+ // nextduedate 部分版本为 Unix 时间戳,统一归一化为 Y-m-d
+ $renew = zjmf_normalize_date((string)($best['nextduedate'] ?? $host['renew_date'] ?? ''));
+ $now = $date ?: date('Y-m-d H:i:s');
+ $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_host
+ SET up_host_id=?, status=?, renew_date=?, updated_at=? WHERE id=?",
+ [$upId, $status, $renew, $now, (int)$host['id']]
+ );
+ $host['up_host_id'] = $upId;
+ $host['status'] = $status;
+ $host['renew_date'] = $renew;
+ return $host;
+}
+
+/** 上游 domainstatus → 本地展示状态(active/suspend/unknown)。 */
+function zjmf_map_upstream_status($status)
+{
+ $st = strtolower(trim((string)$status));
+ if (in_array($st, ['active', 'completed', '运行中'], true)) {
+ return 'active';
+ }
+ if (in_array($st, ['pending', 'wait', 'waiting', '待开通'], true)) {
+ return 'pending';
+ }
+ if (in_array($st, ['suspended', 'suspend', 'paused', '已暂停'], true)) {
+ return 'suspend';
+ }
+ if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'fraud', '已终止'], true)) {
+ return 'terminated';
+ }
+ return 'unknown';
+}
+
/* ============================================================
* 主机操作辅助
* ============================================================ */
@@ -861,9 +1015,11 @@ function zjmf_action_status($action)
function zjmf_host_status_label($status)
{
$map = [
- 'active' => '运行中',
- 'suspend' => '已暂停',
- 'unknown' => '未知',
+ 'active' => '运行中',
+ 'suspend' => '已暂停',
+ 'pending' => '待开通',
+ 'terminated' => '已终止',
+ 'unknown' => '未知',
];
return $map[$status] ?? $status;
}
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/assign.php b/app_plugins/zjmfmanager_reserve/views/admin/assign.php
new file mode 100644
index 0000000..45de4d7
--- /dev/null
+++ b/app_plugins/zjmfmanager_reserve/views/admin/assign.php
@@ -0,0 +1,295 @@
+<?php
+/**
+ * 管理员端 - 主机指派
+ *
+ * 拉取当前上游账户已开通的主机列表(GET host/list),
+ * 勾选具体某台机器并指定目标用户后「批量指派」:
+ * 在本地为该用户绑定该上游主机(不调上游购买/开通),
+ * 主机随即出现在该用户的「我的主机」中。
+ */
+if (!defined('IN_CRONLITE')) {
+ exit;
+}
+mnbt_admin_include('head');
+
+$suppliers = zjmf_supplier_list_all();
+?>
+<div class="container-fluid p-t-15">
+ <div class="card">
+ <div class="card-header">
+ <h4 style="display:inline-block">主机指派(上游已开通机器)</h4>
+ </div>
+ <div class="card-body">
+ <p class="text-muted">
+ 选择供应商并加载其已开通的主机列表,勾选要指派的具体机器并指定目标用户,
+ 点击「批量指派所选主机」。指派为本地绑定操作,不会在上游重复开通;
+ 被指派的主机将出现在该用户的「我的主机」中。
+ </p>
+
+ <!-- 供应商 / 用户 -->
+ <div class="form-row">
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted">供应商 *</label>
+ <select id="zjf-assign-supplier" class="form-control form-control-sm">
+ <?php if (empty($suppliers)): ?>
+ <option value="">请先在供应商管理中新增供应商</option>
+ <?php else: ?>
+ <?php foreach ($suppliers as $s): ?>
+ <option value="<?= (int)$s['id'] ?>"><?=
+ htmlspecialchars($s['name'], ENT_QUOTES)
+ ?><?= (int)$s['status'] !== 1 ? '(已停用)' : '' ?></option>
+ <?php endforeach; ?>
+ <?php endif; ?>
+ </select>
+ </div>
+ <div class="col-md-4 mb-2">
+ <label class="small text-muted">目标用户 *(输入用户名/邮箱/ID 搜索)</label>
+ <div class="input-group input-group-sm">
+ <input type="text" id="zjf-assign-user-kw" class="form-control"
+ placeholder="用户名 / 邮箱 / ID" maxlength="64">
+ <div class="input-group-append">
+ <button type="button" class="btn btn-outline-primary" id="zjf-assign-user-search">搜索</button>
+ </div>
+ </div>
+ <div id="zjf-assign-user-list" class="mt-1"></div>
+ <input type="hidden" id="zjf-assign-user-id" value="0">
+ </div>
+ <div class="col-md-3 mb-2">
+ <label class="small text-muted"> </label>
+ <div>
+ <button type="button" class="btn btn-primary btn-sm" id="zjf-assign-load">加载已开通的主机</button>
+ </div>
+ </div>
+ </div>
+
+ <div id="zjf-assign-tip" class="small text-muted mb-2"></div>
+
+ <!-- 上游已开通主机列表 -->
+ <div class="table-responsive">
+ <table class="table table-bordered table-sm">
+ <thead>
+ <tr>
+ <th style="width:40px;">
+ <input type="checkbox" id="zjf-assign-all"> 全选
+ </th>
+ <th>上游主机ID</th>
+ <th>域名</th>
+ <th>产品名</th>
+ <th>状态</th>
+ <th>IP</th>
+ <th>周期</th>
+ <th>到期日</th>
+ </tr>
+ </thead>
+ <tbody id="zjf-assign-list">
+ <tr><td colspan="8" class="text-center text-muted">
+ 请先选择供应商并加载主机列表
+ </td></tr>
+ </tbody>
+ </table>
+ </div>
+
+ <button type="button" class="btn btn-primary" id="zjf-assign-do">批量指派所选主机</button>
+ <button type="button" class="btn btn-secondary" id="zjf-assign-reset">重置列表</button>
+
+ <!-- 指派结果 -->
+ <div id="zjf-assign-result" class="mt-3" style="display:none;"></div>
+ </div>
+ </div>
+</div>
+<script>
+(function () {
+ function res(res) {
+ var d;
+ try { d = typeof res === 'string' ? JSON.parse(res) : res; } catch (e) { d = {code: res}; }
+ return d;
+ }
+ function notify(d) {
+ var ok = d.qk == 1 || d.success;
+ if (typeof $.notify === 'function') {
+ $.notify({message: d.msg || d.code || '完成'}, {type: ok ? 'success' : 'danger'});
+ } else {
+ alert(d.msg || d.code || '完成');
+ }
+ }
+ function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(/</g, '<')
+ .replace(/>/g, '>').replace(/"/g, '"');
+ }
+
+ var listBox = document.getElementById('zjf-assign-list');
+ var tip = document.getElementById('zjf-assign-tip');
+ var userId = document.getElementById('zjf-assign-user-id');
+ var userList = document.getElementById('zjf-assign-user-list');
+
+ /* ---------------- 用户搜索 ---------------- */
+ document.getElementById('zjf-assign-user-search').addEventListener('click', function () {
+ var kw = document.getElementById('zjf-assign-user-kw').value.trim();
+ if (!kw) { alert('请输入用户名/邮箱/ID'); return; }
+ $.post('ajax.php', {gn: 'p_zjmf_admin_search_users', keyword: kw}, function (r) {
+ var d = res(r);
+ if (d.qk != 1 && !d.success) {
+ userList.innerHTML = '<div class="text-danger small">' + esc(d.msg || '搜索失败') + '</div>';
+ return;
+ }
+ var list = d.list || (d.data && d.data.list) || [];
+ if (!list.length) {
+ userList.innerHTML = '<div class="text-muted small">未找到匹配用户</div>';
+ return;
+ }
+ var html = '';
+ for (var i = 0; i < list.length; i++) {
+ var u = list[i];
+ html += '<label class="d-block border rounded px-2 py-1 mb-1 small">'
+ + '<input type="radio" name="zjf-assign-user" class="mr-1 zjf-assign-user-radio"'
+ + ' value="' + u.id + '" data-name="' + esc(u.username) + '">'
+ + '<b>' + esc(u.username) + '</b>'
+ + (u.email ? ' <span class="text-muted">' + esc(u.email) + '</span>' : '')
+ + (u.qq ? ' <span class="text-muted">QQ:' + esc(u.qq) + '</span>' : '')
+ + ' <span class="text-muted">ID:' + u.id + '</span>'
+ + '</label>';
+ }
+ userList.innerHTML = html;
+ });
+ });
+
+ userList.addEventListener('change', function (e) {
+ if (e.target.classList.contains('zjf-assign-user-radio')) {
+ userId.value = e.target.value;
+ tip.textContent = '已选择用户:' + e.target.getAttribute('data-name');
+ }
+ });
+
+ /* ---------------- 加载上游已开通主机 ---------------- */
+ document.getElementById('zjf-assign-load').addEventListener('click', function () {
+ var supplierId = document.getElementById('zjf-assign-supplier').value;
+ if (!supplierId) { alert('请先选择供应商'); return; }
+ var btn = this;
+ btn.disabled = true;
+ btn.textContent = '加载中...';
+ tip.textContent = '正在拉取该账户已开通的主机列表...';
+ listBox.innerHTML = '<tr><td colspan="8" class="text-center text-muted">加载中...</td></tr>';
+ $.post('ajax.php', {gn: 'p_zjmf_admin_upstream_owned_products', id: supplierId},
+ function (r) {
+ var d = res(r);
+ btn.disabled = false;
+ btn.textContent = '加载已开通的主机';
+ if (d.qk != 1 && !d.success) {
+ tip.textContent = '';
+ listBox.innerHTML = '<tr><td colspan="8" class="text-center text-muted">'
+ + esc(d.msg || '拉取失败') + '</td></tr>';
+ return;
+ }
+ var list = d.list || (d.data && d.data.list) || [];
+ if (!list.length) {
+ listBox.innerHTML = '<tr><td colspan="8" class="text-center text-muted">'
+ + '该账户暂无已开通的主机</td></tr>';
+ return;
+ }
+ var assignedCount = 0;
+ for (var i = 0; i < list.length; i++) {
+ if (list[i].assigned) assignedCount++;
+ }
+ tip.textContent = '共 ' + list.length + ' 台主机(已指派 ' + assignedCount
+ + ' 台,灰显不可重复指派),勾选要指派的主机并指定目标用户后点击「批量指派所选主机」。';
+ var html = '';
+ for (var i = 0; i < list.length; i++) {
+ var it = list[i];
+ var disabled = it.assigned ? ' disabled' : '';
+ html += '<tr' + (it.assigned ? ' class="text-muted"' : '') + '>'
+ + '<td><input type="checkbox" class="zjf-assign-check" value="' + it.id + '"' + disabled + '></td>'
+ + '<td>' + it.id + '</td>'
+ + '<td>' + esc(it.domain || '-') + '</td>'
+ + '<td>' + esc(it.productname || '-') + '</td>'
+ + '<td>' + esc(it.status_desc || it.status || '-') + '</td>'
+ + '<td>' + esc(it.dedicatedip || '-') + '</td>'
+ + '<td>' + esc(it.cycle || '-') + '</td>'
+ + '<td>' + esc(it.nextdue || '-')
+ + (it.assigned ? ' <span class="badge badge-secondary">已指派</span>' : '')
+ + '</td>'
+ + '</tr>';
+ }
+ listBox.innerHTML = html;
+ });
+ });
+
+ document.getElementById('zjf-assign-all').addEventListener('change', function () {
+ var checked = this.checked;
+ document.querySelectorAll('.zjf-assign-check').forEach(function (c) {
+ if (!c.disabled) c.checked = checked;
+ });
+ });
+
+ /* ---------------- 批量指派 ---------------- */
+ document.getElementById('zjf-assign-do').addEventListener('click', function () {
+ var supplierId = document.getElementById('zjf-assign-supplier').value;
+ var uid = parseInt(userId.value, 10);
+ if (!supplierId) { alert('请先选择供应商'); return; }
+ if (!uid) { alert('请先搜索并选择目标用户'); return; }
+ var ids = [];
+ document.querySelectorAll('.zjf-assign-check:checked').forEach(function (c) {
+ ids.push(parseInt(c.value, 10));
+ });
+ if (!ids.length) { alert('请至少勾选一台主机'); return; }
+ if (!confirm('确认为所选用户指派 ' + ids.length + ' 台主机?'
+ + '\n此操作将本地绑定上游已开通的机器,不会在上游重复开通。')) return;
+
+ var btn = this;
+ btn.disabled = true;
+ btn.textContent = '指派中...';
+ var resultBox = document.getElementById('zjf-assign-result');
+ resultBox.style.display = 'block';
+ resultBox.innerHTML = '<div class="text-muted small">正在逐个绑定,请稍候...</div>';
+ $.post('ajax.php', {
+ gn: 'p_zjmf_admin_assign_host',
+ supplier_id: supplierId,
+ user_id: uid,
+ up_host_id: ids
+ }, function (r) {
+ var d = res(r);
+ btn.disabled = false;
+ btn.textContent = '批量指派所选主机';
+ if (d.qk != 1 && !d.success) {
+ resultBox.innerHTML = '<div class="alert alert-danger py-2 small mb-0">'
+ + esc(d.msg || '操作失败') + '</div>';
+ return;
+ }
+ var rows = d.data && d.data.results ? d.data.results : [];
+ var html = '<div class="card"><div class="card-header py-2"><b>指派结果</b></div>'
+ + '<div class="card-body p-0"><table class="table table-bordered table-sm mb-0">'
+ + '<thead><tr><th>机器</th><th>状态</th><th>本地主机ID</th><th>说明</th></tr></thead><tbody>';
+ var okCount = 0;
+ for (var i = 0; i < rows.length; i++) {
+ var it = rows[i];
+ if (it.ok) okCount++;
+ html += '<tr>'
+ + '<td>' + esc(it.domain || ('上游#' + it.up_host_id)) + '</td>'
+ + '<td>' + (it.ok ? '<span class="badge badge-success">指派成功</span>'
+ : '<span class="badge badge-danger">失败</span>') + '</td>'
+ + '<td>' + (it.host_id ? it.host_id : '-') + '</td>'
+ + '<td class="small">' + esc(it.msg || '-') + '</td>'
+ + '</tr>';
+ }
+ html += '</tbody></table></div></div>';
+ if (okCount === rows.length) {
+ html += '<div class="alert alert-success py-2 small mb-0 mt-2">全部指派成功</div>';
+ } else {
+ html += '<div class="alert alert-warning py-2 small mb-0 mt-2">成功 ' + okCount + ' / '
+ + rows.length + ',失败的机器可能已指派给其他用户。</div>';
+ }
+ resultBox.innerHTML = html;
+ });
+ });
+
+ document.getElementById('zjf-assign-reset').addEventListener('click', function () {
+ listBox.innerHTML = '<tr><td colspan="8" class="text-center text-muted">'
+ + '请先选择供应商并加载主机列表</td></tr>';
+ tip.textContent = '';
+ userId.value = 0;
+ userList.innerHTML = '';
+ document.getElementById('zjf-assign-user-kw').value = '';
+ document.getElementById('zjf-assign-all').checked = false;
+ });
+})();
+</script>
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/hosts.php b/app_plugins/zjmfmanager_reserve/views/admin/hosts.php
index 69a1c77..9204093 100644
--- a/app_plugins/zjmfmanager_reserve/views/admin/hosts.php
+++ b/app_plugins/zjmfmanager_reserve/views/admin/hosts.php
@@ -10,22 +10,31 @@ mnbt_admin_include('head');
$page = max(1, (int)($_GET['page_num'] ?? 1));
$hosts = zjmf_host_list_all($page, 30);
$status_labels = [
- 'active' => '运行中',
- 'suspend' => '已暂停',
- 'unknown' => '未知',
+ 'active' => '运行中',
+ 'suspend' => '已暂停',
+ 'pending' => '待开通',
+ 'terminated' => '已终止',
+ 'unknown' => '未知',
];
$status_classes = [
- 'active' => 'badge-success',
- 'suspend' => 'badge-danger',
- 'unknown' => 'badge-secondary',
+ 'active' => 'badge-success',
+ 'suspend' => 'badge-danger',
+ 'pending' => 'badge-warning',
+ 'terminated' => 'badge-dark',
+ 'unknown' => 'badge-secondary',
];
$title = $title ?? '主机管理';
?>
<div class="container-fluid p-t-15">
<div class="card">
- <div class="card-header"><h4 style="display:inline-block">主机管理</h4></div>
+ <div class="card-header">
+ <h4 style="display:inline-block">主机管理</h4>
+ <button type="button" class="btn btn-primary btn-sm float-right" id="zjf-refresh-all">全部刷新状态</button>
+ </div>
<div class="card-body">
- <p class="text-muted">主机映射为开通成功的上游主机。可点击「刷新状态」同步上游实时状态。</p>
+ <p class="text-muted">主机映射为开通成功的上游主机。可点击「刷新状态」同步上游实时状态;
+ 点「全部刷新状态」可批量修复存量未知/过期缓存(逐台上游查询)。</p>
+ <div id="zjf-refresh-all-msg" class="small mb-2" style="display:none;"></div>
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead>
@@ -59,7 +68,7 @@ $title = $title ?? '主机管理';
</span>
</td>
<td><?= htmlspecialchars($h['cycle'] ?: '-') ?></td>
- <td><?= htmlspecialchars($h['renew_date'] ?: '-') ?></td>
+ <td><?= htmlspecialchars(zjmf_normalize_date((string)$h['renew_date']) ?: '-') ?></td>
<td class="small"><?= htmlspecialchars($h['created_at']) ?></td>
<td>
<?php if ((int)$h['up_host_id'] > 0): ?>
@@ -124,4 +133,30 @@ document.querySelectorAll('.zjf-refresh').forEach(function (btn) {
});
});
});
+document.getElementById('zjf-refresh-all').addEventListener('click', function () {
+ var self = this;
+ var msgBox = document.getElementById('zjf-refresh-all-msg');
+ self.disabled = true;
+ self.textContent = '刷新中,请稍候...';
+ msgBox.style.display = 'block';
+ msgBox.className = 'small mb-2 text-muted';
+ msgBox.textContent = '正在逐台上游查询,请勿关闭页面...';
+ $.post('ajax.php', {gn: 'p_zjmf_admin_fetch_all_hosts'}, function (res) {
+ var d;
+ try { d = typeof res === 'string' ? JSON.parse(res) : res; } catch (e) { d = {code: res}; }
+ self.disabled = false;
+ self.textContent = '全部刷新状态';
+ if (d.qk != 1 && !d.success) {
+ msgBox.className = 'small mb-2 text-danger';
+ msgBox.textContent = '刷新失败:' + (d.msg || d.code || '未知错误');
+ return;
+ }
+ var s = d.data || {};
+ msgBox.className = 'small mb-2 text-success';
+ msgBox.textContent = '刷新完成:共 ' + s.total + ' 台,成功 ' + s.ok
+ + ',失败 ' + s.fail + ',缺少上游ID ' + s.no_up
+ + ',状态变更 ' + s.changed + ',仍为未知 ' + s.unknown + '。';
+ setTimeout(function () { location.reload(); }, 1500);
+ });
+});
</script>
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/suppliers.php b/app_plugins/zjmfmanager_reserve/views/admin/suppliers.php
index ffddb19..677577b 100644
--- a/app_plugins/zjmfmanager_reserve/views/admin/suppliers.php
+++ b/app_plugins/zjmfmanager_reserve/views/admin/suppliers.php
@@ -103,6 +103,7 @@ $suppliers = zjmf_supplier_list_all();
<th>站点</th>
<th>加价</th>
<th>超时</th>
+ <th>上游余额</th>
<th>状态</th>
<th>排序</th>
<th>备注</th>
@@ -112,7 +113,7 @@ $suppliers = zjmf_supplier_list_all();
</thead>
<tbody>
<?php if (empty($suppliers)): ?>
- <tr><td colspan="10" class="text-center text-muted">
+ <tr><td colspan="11" class="text-center text-muted">
暂无供应商,请点击右上角「新增供应商」
</td></tr>
<?php else: ?>
@@ -125,6 +126,11 @@ $suppliers = zjmf_supplier_list_all();
htmlspecialchars(zjmf_supplier_markup_label($s), ENT_QUOTES)
?></td>
<td><?= (int)$s['api_timeout'] ?>s</td>
+ <td class="small text-nowrap">
+ <span class="zjf-balance" data-id="<?= (int)$s['id'] ?>">-</span>
+ <button type="button" class="btn btn-sm btn-outline-primary zjf-balance-refresh"
+ data-id="<?= (int)$s['id'] ?>">刷新</button>
+ </td>
<td>
<span class="badge <?= $s['status'] == 1 ? 'badge-success' : 'badge-secondary' ?>">
<?= $s['status'] == 1 ? '启用' : '停用' ?>
@@ -266,6 +272,39 @@ $suppliers = zjmf_supplier_list_all();
});
});
+ function formatCredit(v) {
+ if (v === undefined || v === null || v === '') return '-';
+ var n = Number(v);
+ return isNaN(n) ? String(v) : n.toFixed(2);
+ }
+
+ function loadBalance(id, btn) {
+ $.post('ajax.php', {gn: 'p_zjmf_admin_supplier_balance', id: id}, function (r) {
+ var d = res(r);
+ var ok = d.qk == 1 || d.success;
+ var cell = document.querySelector('.zjf-balance[data-id="' + id + '"]');
+ if (cell) {
+ cell.textContent = ok ? formatCredit(d.credit) : '获取失败';
+ cell.title = ok ? '上游余额(响应字段 credit)' : (d.msg || '获取失败');
+ }
+ if (btn) { btn.disabled = false; btn.textContent = '刷新'; }
+ });
+ }
+
+ document.querySelectorAll('.zjf-balance-refresh').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var id = btn.getAttribute('data-id');
+ btn.disabled = true;
+ btn.textContent = '获取中...';
+ loadBalance(id, btn);
+ });
+ });
+
+ // 页面加载后自动拉取各供应商上游余额
+ document.querySelectorAll('.zjf-balance-refresh').forEach(function (btn) {
+ loadBalance(btn.getAttribute('data-id'), null);
+ });
+
document.querySelectorAll('.zjf-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
post({gn: 'p_zjmf_admin_toggle_supplier', id: btn.getAttribute('data-id')}, true);
diff --git a/app_plugins/zjmfmanager_reserve/views/host.php b/app_plugins/zjmfmanager_reserve/views/host.php
index c2197b9..e390668 100644
--- a/app_plugins/zjmfmanager_reserve/views/host.php
+++ b/app_plugins/zjmfmanager_reserve/views/host.php
@@ -9,10 +9,146 @@ $page_title = $page_title ?? '主机详情';
$host = $host ?? null;
$info = $info ?? ['ok' => false, 'msg' => ''];
$traffic = $traffic ?? ['ok' => false, 'data' => []];
+$dcim = $dcim ?? ['ok' => false, 'data' => []];
+$config_options = $config_options ?? [];
+$custom_fields = $custom_fields ?? [];
+$os_list = $os_list ?? [];
+$os_groups = $os_groups ?? [];
+$os_error = $os_error ?? '';
$password = zjmf_decrypt((string)$host['password']);
$hasUpId = (int)$host['up_host_id'] > 0;
+// 上游详情字段(label => [显示名, 分组])。价格 / 付款相关字段一律不展示。
+$up_fields = [
+ 'productname' => ['产品名', '产品信息'],
+ 'groupname' => ['产品组名', '产品信息'],
+ 'os' => ['操作系统', '产品信息'],
+ 'domain' => ['主机名', '产品信息'],
+ 'dedicatedip' => ['独立 IP', '网络信息'],
+ 'assignedips' => ['附加 IP', '网络信息'],
+ 'ip_num' => ['IP 数量', '网络信息'],
+ 'port' => ['端口', '网络信息'],
+ 'username' => ['服务器用户名', '账号信息'],
+ 'password' => ['服务器密码', '账号信息'],
+ 'regdate' => ['开通时间', '生命周期'],
+ 'nextduedate' => ['到期时间', '生命周期'],
+ 'domainstatus' => ['产品状态', '生命周期'],
+ 'suspendreason' => ['暂停原因', '其他'],
+ 'auto_terminate_end_cycle' => ['到期自动取消', '其他'],
+ 'auto_terminate_reason' => ['取消原因', '其他'],
+ 'bwusage' => ['当前使用流量', '其他'],
+ 'bwlimit' => ['流量上限', '其他'],
+ 'remark' => ['备注', '其他'],
+ 'allow_upgrade_config' => ['支持升级配置', '其他'],
+ 'allow_upgrade_product' => ['支持升级产品', '其他'],
+ 'show_traffic_usage' => ['显示用量图', '其他'],
+ 'productid' => ['产品 ID', '其他'],
+ 'serverid' => ['服务器 ID', '其他'],
+ 'ordernum' => ['上游订单号', '其他'],
+];
+$up_group_order = ['产品信息', '网络信息', '账号信息', '生命周期', '其他'];
+$up_groups = [];
+if (is_array($info['data']) && $info['data'] !== []) {
+ foreach ($up_fields as $k => $cfg) {
+ [$label, $group] = $cfg;
+ if (!array_key_exists($k, $info['data'])) {
+ continue;
+ }
+ $v = $info['data'][$k];
+ if ($v === null || $v === '' || $v === []) {
+ continue;
+ }
+ // 时间戳 → 本地日期时间
+ if (in_array($k, ['regdate', 'nextduedate'], true) && is_numeric($v)) {
+ $v = zjmf_normalize_date((string)$v);
+ }
+ // 布尔开关
+ if (in_array($k, ['auto_terminate_end_cycle', 'allow_upgrade_config', 'allow_upgrade_product', 'show_traffic_usage'], true)) {
+ $v = (string)$v === '1' ? '是' : ((string)$v === '0' ? '否' : $v);
+ }
+ // 流量上限 0 表示不限
+ if ($k === 'bwlimit' && (string)$v === '0') {
+ $v = '不限';
+ }
+ // 状态转中文
+ if ($k === 'domainstatus') {
+ $v = zjmf_host_status_label(zjmf_map_upstream_status((string)$v));
+ }
+ // 上游 password 为明文,直接展示(存本地库时才加密)
+ if ($k === 'password') {
+ $v = (string)$v === '' ? '(空)' : $v;
+ }
+ if (is_array($v)) {
+ $v = json_encode($v, JSON_UNESCAPED_UNICODE);
+ }
+ if ((string)$v === '') {
+ continue;
+ }
+ $up_groups[$group][] = ['label' => $label, 'value' => (string)$v];
+ }
+}
+
+// 端口:host/product 的 host_data.port 是真实端口,优先使用;
+// 为空 / 0 时再依次从配置选项、自定义字段中识别(名称含“端口/port”),同样排除无效值
+$displayPort = '';
+$rawPort = trim((string)($info['data']['port'] ?? ''));
+if ($rawPort !== '' && $rawPort !== '0') {
+ $displayPort = $rawPort;
+}
+if ($displayPort === '') {
+ foreach (($config_options ?: []) as $opt) {
+ $optName = strtolower((string)($opt['name'] ?? ''));
+ if ($optName !== '' && (strpos($optName, '端口') !== false || strpos($optName, 'port') !== false)) {
+ $pv = trim((string)($opt['sub_name'] ?? ''));
+ if ($pv !== '' && $pv !== '0') {
+ $displayPort = $pv;
+ break;
+ }
+ }
+ }
+}
+if ($displayPort === '') {
+ foreach (($custom_fields ?: []) as $cf) {
+ $cfName = strtolower((string)($cf['fieldname'] ?? $cf['name'] ?? ''));
+ if ($cfName !== '' && (strpos($cfName, '端口') !== false || strpos($cfName, 'port') !== false)) {
+ $cfv = trim((string)($cf['value'] ?? ''));
+ if ($cfv !== '' && $cfv !== '0') {
+ $displayPort = $cfv;
+ break;
+ }
+ }
+ }
+}
+// DCIM 交换机端口名兜底(dcim/detail 的 switch[].name)
+if ($displayPort === '') {
+ $switchList = $dcim['data']['detail']['switch'] ?? [];
+ if (is_array($switchList) && isset($switchList[0]['name'])) {
+ $displayPort = trim((string)$switchList[0]['name']);
+ }
+}
+if ($displayPort === '0') {
+ $displayPort = '';
+}
+// 端口行统一用正确端口,无效(空 / 0)则不展示
+if ($displayPort !== '' && isset($up_groups['网络信息'])) {
+ foreach ($up_groups['网络信息'] as $i => $r) {
+ if ($r['label'] === '端口') {
+ $up_groups['网络信息'][$i]['value'] = $displayPort;
+ break;
+ }
+ }
+}
+// 配置选项:剔除已单独展示的端口项,避免重复
+if (is_array($config_options)) {
+ $config_options = array_values(array_filter($config_options, function ($opt) {
+ $name = strtolower((string)($opt['name'] ?? ''));
+ return !($name !== '' && (strpos($name, '端口') !== false || strpos($name, 'port') !== false));
+ }));
+}
+
+$liveIp = (string)($info['data']['dedicatedip'] ?? $info['data']['ip'] ?? '');
+
function zjmf_view_show_traffic($data)
{
if (!is_array($data) || $data === []) {
@@ -32,60 +168,269 @@ function zjmf_view_show_traffic($data)
}
ob_start();
?>
-<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
- <h1 style="font-size:20px;color:#222;margin:0;"><?= htmlspecialchars($host['name']) ?></h1>
- <a class="layui-btn layui-btn-xs layui-btn-primary" href="<?= zjmf_url('reserve/hosts') ?>">返回主机列表</a>
+<style>
+.zj-hd{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;
+ margin-bottom:20px;padding:24px 26px;border-radius:12px;color:#fff;
+ background:linear-gradient(120deg,#1677e0 0%,#3fa9ff 55%,#63c3ff 100%);}
+.zj-hd-title{font-size:22px;font-weight:700;margin:0 0 6px;color:#fff;letter-spacing:.3px;}
+.zj-hd-meta{font-size:12px;color:rgba(255,255,255,.88);}
+.zj-hd-right{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
+.zj-btn-ghost{display:inline-flex;align-items:center;padding:7px 16px;border-radius:20px;
+ background:rgba(255,255,255,.16);color:#fff;font-size:13px;text-decoration:none;transition:background .15s;}
+.zj-btn-ghost:hover{background:rgba(255,255,255,.3);color:#fff;text-decoration:none;}
+.zj-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;}
+.zj-stat{background:#fff;border:1px solid #eef0f3;border-radius:12px;padding:16px 18px;
+ box-shadow:0 1px 2px rgba(16,24,40,.04);}
+.zj-stat-label{font-size:12px;color:#98a2b3;margin-bottom:8px;}
+.zj-stat-value{font-size:15px;font-weight:600;color:#1f2329;word-break:break-all;line-height:1.5;}
+.zj-panel{background:#fff;border:1px solid #eef0f3;border-radius:12px;margin-bottom:20px;
+ overflow:hidden;box-shadow:0 1px 2px rgba(16,24,40,.04);}
+.zj-panel-title{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:600;color:#1f2329;
+ padding:14px 20px;border-bottom:1px solid #f2f3f5;}
+.zj-panel-title::before{content:'';width:4px;height:14px;border-radius:2px;background:#1e9fff;}
+.zj-panel-body{padding:6px 20px 16px;}
+.zj-group{margin-top:12px;}
+.zj-group-title{font-size:12px;color:#98a2b3;font-weight:600;margin-bottom:2px;letter-spacing:.5px;}
+.zj-kv{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 36px;}
+.zj-kv-item{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;
+ padding:9px 0;border-bottom:1px dashed #f0f1f3;font-size:13px;}
+.zj-kv-label{color:#86909c;flex-shrink:0;}
+.zj-kv-value{color:#1f2329;text-align:right;word-break:break-all;}
+.zj-tip{font-size:12px;color:#98a2b3;}
+.zj-panel-body .zj-desc2{padding:6px 0 0;font-size:13px;color:#555;line-height:1.7;}
+@media(max-width:760px){
+ .zj-stats{grid-template-columns:repeat(2,1fr);}
+ .zj-kv{grid-template-columns:1fr;}
+ .zj-kv-item{flex-direction:column;gap:2px;}
+ .zj-kv-value{text-align:left;}
+}
+</style>
+
+<div class="zj-hd">
+ <div>
+ <h1 class="zj-hd-title"><?= htmlspecialchars($host['name']) ?></h1>
+ <div class="zj-hd-meta">
+ <?= htmlspecialchars(($host['supplier_name'] ?? '') ?: '魔方财务上游') ?>
+ <?php if (!empty($host['product_name'])): ?>
+ · <?= htmlspecialchars($host['product_name']) ?>
+ <?php endif; ?>
+ · 上游主机 ID:<?= (int)$host['up_host_id'] ?>
+ </div>
+ </div>
+ <div class="zj-hd-right">
+ <a class="zj-btn-ghost" href="<?= zjmf_url('reserve/hosts') ?>">返回主机列表</a>
+ </div>
</div>
<div class="zj-msg" id="zjf-msg"></div>
-<div class="layui-card">
- <div class="layui-card-header">主机信息</div>
- <div class="layui-card-body" style="padding:0;">
- <table class="zj-table">
- <tbody>
- <tr><td style="width:120px;">状态</td><td>
- <span class="zj-status zj-status-<?= htmlspecialchars($host['status']) ?>">
- <?= htmlspecialchars(zjmf_host_status_label($host['status'])) ?>
+<div class="zj-stats">
+ <div class="zj-stat">
+ <div class="zj-stat-label">状态</div>
+ <div class="zj-stat-value">
+ <span class="zj-status zj-status-<?= htmlspecialchars($host['status']) ?>">
+ <?= htmlspecialchars(zjmf_host_status_label($host['status'])) ?>
+ </span>
+ <?php if (!empty($info['ok']) && $info['status'] !== $host['status']): ?>
+ <span class="zj-tip">实时:<?= htmlspecialchars(zjmf_host_status_label($info['status'])) ?></span>
+ <?php endif; ?>
+ </div>
+ </div>
+ <div class="zj-stat">
+ <div class="zj-stat-label">IP 地址</div>
+ <div class="zj-stat-value zj-mono"><?= htmlspecialchars($liveIp ?: '-') ?></div>
+ </div>
+ <div class="zj-stat">
+ <div class="zj-stat-label">端口</div>
+ <div class="zj-stat-value zj-mono"><?= htmlspecialchars($displayPort ?: '-') ?></div>
+ </div>
+ <div class="zj-stat">
+ <div class="zj-stat-label">到期时间</div>
+ <div class="zj-stat-value"><?= htmlspecialchars($host['renew_date'] ?: '-') ?></div>
+ </div>
+ <div class="zj-stat">
+ <div class="zj-stat-label">用户名</div>
+ <div class="zj-stat-value zj-mono"><?= htmlspecialchars($host['username'] ?: '-') ?></div>
+ </div>
+ <div class="zj-stat">
+ <div class="zj-stat-label">密码</div>
+ <div class="zj-stat-value zj-mono"><?= htmlspecialchars($password ?: '-') ?></div>
+ </div>
+ <div class="zj-stat">
+ <div class="zj-stat-label">周期</div>
+ <div class="zj-stat-value"><?= htmlspecialchars($host['cycle'] ?: '-') ?></div>
+ </div>
+ <div class="zj-stat">
+ <div class="zj-stat-label">上游主机 ID</div>
+ <div class="zj-stat-value zj-mono"><?= (int)$host['up_host_id'] ?></div>
+ </div>
+</div>
+
+<?php if ($up_groups !== []): ?>
+<div class="zj-panel">
+ <div class="zj-panel-title">上游详情</div>
+ <div class="zj-panel-body">
+ <?php foreach ($up_group_order as $gname): ?>
+ <?php if (empty($up_groups[$gname])) continue; ?>
+ <div class="zj-group">
+ <div class="zj-group-title"><?= htmlspecialchars($gname) ?></div>
+ <div class="zj-kv">
+ <?php foreach ($up_groups[$gname] as $r): ?>
+ <div class="zj-kv-item">
+ <span class="zj-kv-label"><?= htmlspecialchars($r['label']) ?></span>
+ <span class="zj-kv-value <?= in_array($r['label'], ['独立 IP', '附加 IP', '服务器用户名', '服务器密码', '端口', '上游订单号'], true) ? 'zj-mono' : '' ?>"><?=
+ htmlspecialchars($r['value'])
+ ?></span>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+</div>
+<?php endif; ?>
+
+<?php if ($config_options !== []): ?>
+<div class="zj-panel">
+ <div class="zj-panel-title">配置选项</div>
+ <div class="zj-panel-body">
+ <div class="zj-kv">
+ <?php foreach ($config_options as $opt): ?>
+ <div class="zj-kv-item">
+ <span class="zj-kv-label"><?= htmlspecialchars((string)($opt['name'] ?? '')) ?></span>
+ <span class="zj-kv-value"><?= htmlspecialchars((string)($opt['sub_name'] ?? '')) ?></span>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ </div>
+</div>
+<?php endif; ?>
+
+<?php if ($custom_fields !== []): ?>
+<div class="zj-panel">
+ <div class="zj-panel-title">自定义字段</div>
+ <div class="zj-panel-body">
+ <div class="zj-kv">
+ <?php foreach ($custom_fields as $cf): ?>
+ <div class="zj-kv-item">
+ <span class="zj-kv-label"><?= htmlspecialchars((string)($cf['fieldname'] ?? $cf['name'] ?? '')) ?></span>
+ <span class="zj-kv-value"><?= htmlspecialchars((string)($cf['value'] ?? '')) ?></span>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ </div>
+</div>
+<?php endif; ?>
+
+<?php
+// DCIM 信息(电源/交换机端口/重装次数/任务进度),仅 DCIM 产品返回
+$dcimData = is_array($dcim['data'] ?? null) ? $dcim['data'] : [];
+$dcimPower = (string)($dcimData['power'] ?? '');
+$dcimSwitch = is_array($dcimData['detail']['switch'] ?? null) ? $dcimData['detail']['switch'] : [];
+$dcimReinstall = is_array($dcimData['reinstall'] ?? null) ? $dcimData['reinstall'] : [];
+$dcimTask = is_array($dcimData['task'] ?? null) ? $dcimData['task'] : [];
+$hasDcim = $dcimPower !== '' || $dcimSwitch !== [] || $dcimReinstall !== [] || isset($dcimTask['progress']) || !empty($dcimTask['step']);
+$taskTypeMap = ['0' => '重装系统', '1' => '救援系统', '2' => '重置密码', '3' => '获取硬件信息'];
+?>
+<?php if ($hasDcim): ?>
+<div class="zj-panel">
+ <div class="zj-panel-title">DCIM 信息</div>
+ <div class="zj-panel-body">
+ <?php if ($dcimPower !== ''): ?>
+ <div class="zj-kv">
+ <div class="zj-kv-item">
+ <span class="zj-kv-label">电源状态</span>
+ <span class="zj-kv-value">
+ <?php if ($dcimPower === 'on'): ?><span class="zj-status zj-status-active">已开机</span>
+ <?php elseif ($dcimPower === 'off'): ?><span class="zj-status zj-status-suspend">已关机</span>
+ <?php else: ?><span class="zj-tip"><?= htmlspecialchars($dcimPower . ($dcimData['power_msg'] !== '' ? '(' . $dcimData['power_msg'] . ')' : '')) ?></span>
+ <?php endif; ?>
</span>
- <?php if (!empty($info['ok']) && $info['status'] !== $host['status']): ?>
- <span class="zj-muted">(实时:<?= htmlspecialchars(zjmf_host_status_label($info['status'])) ?>)</span>
+ </div>
+ </div>
+ <?php endif; ?>
+ <?php if ($dcimSwitch !== []): ?>
+ <div class="zj-group">
+ <div class="zj-group-title">交换机端口</div>
+ <div class="zj-kv">
+ <?php foreach ($dcimSwitch as $sw): ?>
+ <div class="zj-kv-item">
+ <span class="zj-kv-label">交换机 #<?= htmlspecialchars((string)($sw['switch_id'] ?? '')) ?></span>
+ <span class="zj-kv-value zj-mono"><?= htmlspecialchars((string)($sw['name'] ?? '-')) ?></span>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ </div>
+ <?php endif; ?>
+ <?php if ($dcimReinstall !== []): ?>
+ <div class="zj-group">
+ <div class="zj-group-title">重装次数</div>
+ <div class="zj-kv">
+ <div class="zj-kv-item">
+ <span class="zj-kv-label">本周已用 / 上限</span>
+ <span class="zj-kv-value">
+ <?= htmlspecialchars((string)($dcimReinstall['num'] ?? '-')) ?> /
+ <?= htmlspecialchars((string)($dcimReinstall['max_times'] ?? '0')) ?>
+ <?php if ((string)($dcimReinstall['max_times'] ?? '') === '0'): ?><span class="zj-tip">(不限)</span><?php endif; ?>
+ <?php if (!empty($dcimReinstall['price'])): ?><span class="zj-tip">已达上限,可购买重装次数</span><?php endif; ?>
+ </span>
+ </div>
+ </div>
+ </div>
+ <?php endif; ?>
+ <?php if (isset($dcimTask['progress']) || !empty($dcimTask['step'])): ?>
+ <div class="zj-group">
+ <div class="zj-group-title">当前任务</div>
+ <div class="zj-kv">
+ <div class="zj-kv-item">
+ <span class="zj-kv-label">任务进度</span>
+ <span class="zj-kv-value">
+ <?= htmlspecialchars((string)($dcimTask['progress'] ?? '0')) ?>%
+ <?php if (isset($taskTypeMap[(string)($dcimTask['task_type'] ?? '')])): ?>
+ <span class="zj-tip">(<?= htmlspecialchars($taskTypeMap[(string)$dcimTask['task_type']]) ?>)</span>
+ <?php endif; ?>
+ </span>
+ </div>
+ <?php if (!empty($dcimTask['step'])): ?>
+ <div class="zj-kv-item"><span class="zj-kv-label">当前步骤</span><span class="zj-kv-value"><?= htmlspecialchars((string)$dcimTask['step']) ?></span></div>
+ <?php endif; ?>
+ <?php if (!empty($dcimTask['last_result']['status'])): ?>
+ <div class="zj-kv-item"><span class="zj-kv-label">上次结果</span><span class="zj-kv-value"><?= (int)$dcimTask['last_result']['status'] === 1 ? '成功' : '失败' ?></span></div>
<?php endif; ?>
- </td></tr>
- <tr><td>供应商</td><td><?= htmlspecialchars($host['supplier_name'] ?: '-') ?></td></tr>
- <tr><td>用户名</td><td class="zj-mono" id="zjf-username"><?=
- htmlspecialchars(zjmf_mask_account($host['username']))
- ?></td></tr>
- <tr><td>密码</td><td class="zj-mono" id="zjf-password"><?= $password !== '' ? '••••••••' : '-' ?></td></tr>
- <tr><td>周期</td><td><?= htmlspecialchars($host['cycle'] ?: '-') ?></td></tr>
- <tr><td>到期时间</td><td><?= htmlspecialchars($host['renew_date'] ?: '-') ?></td></tr>
- <tr><td>上游主机 ID</td><td class="zj-mono"><?= (int)$host['up_host_id'] ?></td></tr>
- </tbody>
- </table>
+ <?php if (!empty($dcimTask['error_msg'])): ?>
+ <div class="zj-kv-item"><span class="zj-kv-label">提示</span><span class="zj-kv-value" style="color:#c62828;"><?= htmlspecialchars((string)$dcimTask['error_msg']) ?></span></div>
+ <?php endif; ?>
+ </div>
+ </div>
+ <?php endif; ?>
</div>
</div>
+<?php endif; ?>
-<div class="layui-card">
- <div class="layui-card-header">流量使用</div>
- <div class="layui-card-body" style="padding:16px;font-size:13px;color:#555;">
+<div class="zj-panel">
+ <div class="zj-panel-title">流量使用</div>
+ <div class="zj-panel-body">
<?php if (!empty($traffic['ok'])): ?>
- <?= zjmf_view_show_traffic($traffic['data']) ?>
+ <div class="zj-desc2"><?= zjmf_view_show_traffic($traffic['data']) ?></div>
<?php else: ?>
- <span class="zj-muted"><?= (($traffic['msg'] ?? '') ?: '流量查询失败') ?></span>
+ <span class="zj-tip"><?= (($traffic['msg'] ?? '') ?: '流量查询失败') ?></span>
<?php endif; ?>
</div>
</div>
<?php if ($hasUpId): ?>
-<div class="layui-card">
- <div class="layui-card-header">主机操作</div>
- <div class="layui-card-body">
- <p class="zj-muted" style="margin-top:0;">重启 / 重装 / 重置密码为高危操作,请谨慎执行。</p>
+<div class="zj-panel">
+ <div class="zj-panel-title">主机操作</div>
+ <div class="zj-panel-body">
+ <p class="zj-tip" style="margin:8px 0 14px;">重启 / 重装 / 救援 / 重置密码为高危操作,请谨慎执行。</p>
<button type="button" class="layui-btn layui-btn-sm" data-act="on">开机</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-warm" data-act="off">关机</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-warm" data-act="reboot">重启</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-danger" data-act="reinstall">重装系统</button>
+ <button type="button" class="layui-btn layui-btn-sm layui-btn-danger" data-act="rescue">救援系统</button>
<button type="button" class="layui-btn layui-btn-sm" data-act="reset_password">重置密码</button>
+ <button type="button" class="layui-btn layui-btn-sm" data-act="bmc">重置 BMC</button>
+ <button type="button" class="layui-btn layui-btn-sm" data-act="cancel_task">取消任务</button>
<div id="zjf-pass-panel" style="display:none;margin-top:14px;padding:14px;
background:#fafbfc;border:1px solid #eee;border-radius:6px;">
@@ -94,6 +439,53 @@ ob_start();
<button type="button" class="layui-btn layui-btn-sm" id="zjf-pass-confirm"
style="margin-top:10px;">确认重置</button>
</div>
+
+ <?php if ($os_list !== []): ?>
+ <div id="zjf-reinstall-panel" style="display:none;margin-top:14px;padding:14px;
+ background:#fafbfc;border:1px solid #eee;border-radius:6px;">
+ <select id="zjf-os-select" class="layui-input" style="max-width:280px;display:inline-block;">
+ <option value="">请选择操作系统</option>
+ <?php
+ // 系统列表按分组渲染(host/dedicatedserver 返回 cloud_os + cloud_os_group)
+ $groupNames = [];
+ foreach ($os_groups as $g) {
+ $groupNames[(string)($g['id'] ?? '')] = (string)($g['name'] ?? '');
+ }
+ $osByGroup = [];
+ foreach ($os_list as $os) {
+ $osByGroup[(string)($os['group'] ?? '')][] = $os;
+ }
+ if ($osByGroup === []) {
+ $osByGroup[''] = $os_list;
+ }
+ foreach ($osByGroup as $gid => $items):
+ $gLabel = $gid !== '' ? (string)($groupNames[$gid] ?? $gid) : '';
+ ?>
+ <?php if ($gLabel !== ''): ?>
+ <optgroup label="<?= htmlspecialchars($gLabel) ?>">
+ <?php endif; ?>
+ <?php foreach ($items as $os): ?>
+ <option value="<?= (int)($os['id'] ?? 0) ?>" data-group="<?= htmlspecialchars($gLabel) ?>"><?= htmlspecialchars((string)($os['name'] ?? '')) ?></option>
+ <?php endforeach; ?>
+ <?php if ($gLabel !== ''): ?>
+ </optgroup>
+ <?php endif; ?>
+ <?php endforeach; ?>
+ </select>
+ <button type="button" class="layui-btn layui-btn-sm layui-btn-danger" id="zjf-reinstall-confirm"
+ style="margin-top:10px;">确认重装</button>
+ </div>
+ <?php endif; ?>
+
+ <div id="zjf-rescue-panel" style="display:none;margin-top:14px;padding:14px;
+ background:#fafbfc;border:1px solid #eee;border-radius:6px;">
+ <select id="zjf-rescue-system" class="layui-input" style="max-width:160px;display:inline-block;">
+ <option value="1">Linux</option>
+ <option value="2">Windows</option>
+ </select>
+ <button type="button" class="layui-btn layui-btn-sm layui-btn-danger" id="zjf-rescue-confirm"
+ style="margin-left:10px;">确认进入救援系统</button>
+ </div>
</div>
</div>
<?php endif; ?>
@@ -103,12 +495,21 @@ ob_start();
var msg = document.getElementById('zjf-msg');
var passPanel = document.getElementById('zjf-pass-panel');
var passInput = document.getElementById('zjf-pass-input');
+ var reinstallPanel = document.getElementById('zjf-reinstall-panel');
+ var rescuePanel = document.getElementById('zjf-rescue-panel');
+ var osCount = <?= is_array($os_list) ? count($os_list) : 0 ?>;
+ var osError = <?= json_encode((string)$os_error, JSON_UNESCAPED_UNICODE) ?>;
var pendingAction = '';
function show(text, ok) {
msg.textContent = text;
msg.className = 'zj-msg zj-msg-show ' + (ok ? 'zj-msg-success' : 'zj-msg-error');
}
+ function hidePanels() {
+ if (passPanel) passPanel.style.display = 'none';
+ if (reinstallPanel) reinstallPanel.style.display = 'none';
+ if (rescuePanel) rescuePanel.style.display = 'none';
+ }
function call(action, extra) {
var body = new URLSearchParams();
body.append('host_id', '<?= (int)$host['id'] ?>');
@@ -130,6 +531,7 @@ ob_start();
document.querySelectorAll('[data-act]').forEach(function (btn) {
btn.addEventListener('click', function () {
var act = btn.getAttribute('data-act');
+ hidePanels();
if (act === 'reset_password') {
pendingAction = act;
passPanel.style.display = 'block';
@@ -137,7 +539,32 @@ ob_start();
return;
}
if (act === 'reinstall') {
- if (!confirm('确定要重装该系统吗?重装将清空数据且不可恢复!')) return;
+ // 系统列表来自 host/dedicatedserver,重装统一走 dcim/reinstall(共用重装端点)
+ if (osCount > 0 && reinstallPanel) {
+ reinstallPanel.style.display = 'block';
+ return;
+ }
+ show('无法获取系统列表:' + osError, false);
+ return;
+ }
+ if (act === 'rescue') {
+ if (rescuePanel) {
+ rescuePanel.style.display = 'block';
+ return;
+ }
+ if (!confirm('确定进入救援系统(Linux)吗?')) return;
+ call('rescue', {system: 1});
+ return;
+ }
+ if (act === 'bmc') {
+ if (!confirm('确定要重置该主机的 BMC 吗?')) return;
+ call('bmc', {});
+ return;
+ }
+ if (act === 'cancel_task') {
+ if (!confirm('确定要取消当前的重装/救援/重置密码任务吗?')) return;
+ call('cancel_task', {});
+ return;
}
if (act === 'reboot') {
if (!confirm('确定要重启该主机吗?')) return;
@@ -154,6 +581,28 @@ ob_start();
}
call('reset_password', {password: pwd});
});
+
+ if (reinstallPanel) {
+ document.getElementById('zjf-reinstall-confirm').addEventListener('click', function () {
+ var sel = document.getElementById('zjf-os-select');
+ var os = sel.value;
+ if (!os) { show('请选择操作系统', false); return; }
+ var group = '';
+ if (sel.selectedIndex >= 0 && sel.options[sel.selectedIndex]) {
+ group = sel.options[sel.selectedIndex].getAttribute('data-group') || '';
+ }
+ if (!confirm('确定要重装该系统吗?重装将清空数据且不可恢复!')) return;
+ call('dcim_reinstall', {os: os, os_group: group});
+ });
+ }
+
+ if (rescuePanel) {
+ document.getElementById('zjf-rescue-confirm').addEventListener('click', function () {
+ var system = document.getElementById('zjf-rescue-system').value;
+ if (!confirm('确定进入救援系统吗?当前数据可能受影响,请谨慎操作。')) return;
+ call('rescue', {system: system});
+ });
+ }
})();
</script>
<?php
diff --git a/app_plugins/zjmfmanager_reserve/views/hosts.php b/app_plugins/zjmfmanager_reserve/views/hosts.php
index 0114a00..ae18390 100644
--- a/app_plugins/zjmfmanager_reserve/views/hosts.php
+++ b/app_plugins/zjmfmanager_reserve/views/hosts.php
@@ -49,7 +49,7 @@ ob_start();
<?= htmlspecialchars(zjmf_host_status_label($host['status'])) ?>
</span>
</td>
- <td class="zj-mono"><?= htmlspecialchars(zjmf_mask_account($host['username'])) ?></td>
+ <td class="zj-mono"><?= htmlspecialchars(($host['username'] ?: '-')) ?></td>
<td><?= htmlspecialchars($host['cycle'] ?: '-') ?></td>
<td><?= htmlspecialchars($host['renew_date'] ?: '-') ?></td>
<td>
diff --git a/templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue b/templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue
index f194e68..b845785 100644
--- a/templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue
+++ b/templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue
@@ -24,6 +24,10 @@
</div>
<div class="zjmf-asset-meta">
+ <div class="meta-item">
+ <span>供应商</span>
+ <b>{{ h.supplier_name || '—' }}</b>
+ </div>
<div class="meta-item">
<span>主机账号</span>
<b class="mono">{{ h.username || '—' }}</b>
@@ -132,7 +136,7 @@ const newPassword = ref('')
const pwdSubmitting = ref(false)
function statusText(status) {
- return { active: '运行中', suspend: '已暂停', unknown: '未知' }[status] || status
+ return { active: '运行中', suspend: '已暂停', pending: '待开通', terminated: '已终止', unknown: '未知' }[status] || status
}
function cycleText(cycle) {
@@ -150,6 +154,8 @@ function cycleText(cycle) {
function statusTheme(status) {
if (status === 'active') return 'success'
if (status === 'suspend') return 'warning'
+ if (status === 'pending') return 'warning'
+ if (status === 'terminated') return 'danger'
return 'default'
}