feat(zjmf): 完成魔方财务代理分销云服务器插件功能集成
18 个文件变更
+1427
-53
13172193298@163.com
| •app_plugins/zjmfmanager_reserve/assets/style.css | +4 -0 |
| •app_plugins/zjmfmanager_reserve/bootstrap.php | +150 -6 |
| •app_plugins/zjmfmanager_reserve/lib/upstream.php | +188 -25 |
| •app_plugins/zjmfmanager_reserve/lib/zjmf.php | +63 -20 |
| •app_plugins/zjmfmanager_reserve/views/admin/products.php | +58 -0 |
| •app_plugins/zjmfmanager_reserve/views/order.php | +1 -1 |
| •app_plugins/zjmfmanager_reserve/views/shop.php | +1 -1 |
| •templates/tdesign/account/_spa_boot.php | +1 -0 |
| •templates/tdesign/home/index.php | +1 -0 |
| •templates/tdesign/spa/src/account/api/plugins.js | +34 -0 |
| •templates/tdesign/spa/src/account/layouts/AccountLayout.vue | +45 -0 |
| •templates/tdesign/spa/src/account/router/index.js | +18 -0 |
| •templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue | +309 -0 |
| •templates/tdesign/spa/src/account/views/ZjmfOrdersView.vue | +189 -0 |
| •templates/tdesign/spa/src/account/views/ZjmfShopView.vue | +279 -0 |
| •templates/tdesign/spa/src/home/api/zjmf.js | +13 -0 |
| •templates/tdesign/spa/src/home/styles/home.scss | +13 -0 |
| •templates/tdesign/spa/src/home/views/LandingView.vue | +60 -0 |
变更内容
diff --git a/app_plugins/zjmfmanager_reserve/assets/style.css b/app_plugins/zjmfmanager_reserve/assets/style.css
index f9c88c6..f070bf3 100644
--- a/app_plugins/zjmfmanager_reserve/assets/style.css
+++ b/app_plugins/zjmfmanager_reserve/assets/style.css
@@ -27,6 +27,10 @@
.zj-card-desc img{max-width:100%;}
.zj-desc{padding:10px 2px 18px;font-size:13px;color:#666;line-height:1.7;overflow-wrap:break-word;}
.zj-desc img{max-width:100%;}
+/* 上游简介 li 列表:显式声明保证每项独占一行、带圆点(不受全局 reset 影响) */
+.zj-card-desc ul,.zj-desc ul{margin:6px 0 6px 18px;padding:0;}
+.zj-card-desc li,.zj-desc li{display:list-item;list-style:disc;margin:3px 0;padding:0;line-height:1.6;}
+.zj-card-desc ul li ul,.zj-desc ul li ul{list-style:circle;}
.zj-price{display:flex;gap:14px;padding:12px 20px;background:#fafbfc;border-top:1px solid #f0f0f0;flex-wrap:wrap;}
.zj-price-item{display:flex;align-items:center;gap:4px;font-size:12px;}
.zj-price-label{background:#e8e8e8;color:#666;padding:1px 6px;border-radius:3px;}
diff --git a/app_plugins/zjmfmanager_reserve/bootstrap.php b/app_plugins/zjmfmanager_reserve/bootstrap.php
index 477f0f3..a0a7ab4 100644
--- a/app_plugins/zjmfmanager_reserve/bootstrap.php
+++ b/app_plugins/zjmfmanager_reserve/bootstrap.php
@@ -378,6 +378,56 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_save_product', function () {
json_exit_success('已保存');
});
+// 保存商品各周期售价(管理员直接定价,覆盖加价规则)
+mnbt_register_ajax('admin', 'p_zjmf_admin_save_cycles', function () {
+ mnbt_plugin_require_admin();
+ global $DB, $date;
+
+ $id = (int)($_POST['id'] ?? 0);
+ $product = zjmf_product_get($id);
+ if (!$product) {
+ json_exit_error('商品不存在');
+ }
+
+ $overrides = (isset($_POST['overrides']) && is_array($_POST['overrides']))
+ ? $_POST['overrides'] : [];
+ if ($overrides === []) {
+ json_exit_error('请至少填写一个周期的售价');
+ }
+
+ $now = $date ?: date('Y-m-d H:i:s');
+ $known = zjmf_cycles();
+ $agentCents = max(0, (int)$product['agent_price_cents']);
+ $old = zjmf_product_cycles($product);
+ $save = [];
+ foreach ($overrides as $cycle => $val) {
+ $cycle = trim((string)$cycle);
+ if ($cycle === '' || !is_numeric($val)) {
+ continue;
+ }
+ $override = max(0, (int)round((float)$val * 100));
+ $entry = $old[$cycle] ?? [];
+ $save[$cycle] = [
+ 'cycle' => $cycle,
+ 'name' => (string)($entry['name'] ?? $known[$cycle]['name'] ?? $cycle),
+ 'agent_price_cents' => (int)($entry['agent_price_cents'] ?? $agentCents),
+ 'price_cents' => $override,
+ 'override' => $override,
+ ];
+ }
+ if ($save === []) {
+ json_exit_error('周期售价参数无效');
+ }
+ $ok = $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_product SET cycles=?, updated_at=? WHERE id=?",
+ [json_encode(array_values($save), JSON_UNESCAPED_UNICODE), $now, $id]
+ );
+ if (!$ok) {
+ json_exit_error('保存失败');
+ }
+ json_exit_success('周期售价已保存');
+});
+
// 切换商品上架/下架
mnbt_register_ajax('admin', 'p_zjmf_admin_toggle_product', function () {
mnbt_plugin_require_admin();
@@ -410,6 +460,28 @@ mnbt_register_route('GET', '/reserve/shop', function ($params, $ctx) {
]);
});
+// 公开商品列表 API(供官网首页/游客展示,无需登录)
+mnbt_register_route('GET', '/reserve/api/public_products', function ($params, $ctx) {
+ $list = [];
+ foreach (zjmf_product_list_active() as $p) {
+ $min = 0;
+ foreach (zjmf_product_cycles($p) as $cfg) {
+ $c = (int)$cfg['price_cents'];
+ if ($c > 0 && ($min === 0 || $c < $min)) {
+ $min = $c;
+ }
+ }
+ $list[] = [
+ 'id' => (int)$p['id'],
+ 'name' => (string)$p['name'],
+ 'description' => zjmf_render_description((string)($p['description'] ?? '')),
+ 'currency' => (string)($p['currency'] ?? ''),
+ 'min_price_cents'=> $min,
+ ];
+ }
+ zjmf_json('ok', ['list' => $list]);
+});
+
// 下单页(选周期 + 支付方式)
mnbt_register_route('GET', '/reserve/product/{product_id}', function ($params, $ctx) {
zjmf_require_user();
@@ -422,7 +494,7 @@ mnbt_register_route('GET', '/reserve/product/{product_id}', function ($params, $
}
if (!zjmf_supplier_usable((int)$product['supplier_id'])) {
http_response_code(404);
- echo '商品所属供应商已停用';
+ echo '该商品已暂停销售';
return;
}
$methods = function_exists('mnbt_get_enabled_payment_methods')
@@ -456,7 +528,7 @@ mnbt_register_route('POST', '/reserve/api/create_order', function ($params, $ctx
zjmf_json('商品不存在或已下架');
}
if (!zjmf_supplier_usable((int)$product['supplier_id'])) {
- zjmf_json('商品所属供应商已停用,无法下单');
+ zjmf_json('该商品已暂停销售,无法下单');
}
$cycles = zjmf_product_cycles($product);
if (!isset($cycles[$cycle])) {
@@ -519,12 +591,84 @@ mnbt_register_route('POST', '/reserve/api/create_order', function ($params, $ctx
zjmf_json('支付方式不可用,请检查支付插件是否已启用');
}
- zjmf_json('正在跳转到支付页面', [
+ zjmf_json('ok', [
'html' => $html,
'order_no' => $order_no,
+ 'msg' => '正在跳转到支付页面',
]);
});
+/* ============================================================
+ * 用户端 API 路由(SPA 数据接口)
+ * ============================================================ */
+
+// 上架商品列表(含各周期价格,游客可访问,供 account SPA 商城页)
+mnbt_register_route('GET', '/reserve/api/products', function ($params, $ctx) {
+ $list = [];
+ foreach (zjmf_product_list_active() as $p) {
+ $cycles = [];
+ foreach (zjmf_product_cycles($p) as $key => $cfg) {
+ $cycles[] = [
+ 'key' => $key,
+ 'name' => $cfg['name'],
+ 'price_cents' => $cfg['price_cents'],
+ ];
+ }
+ $list[] = [
+ 'id' => (int)$p['id'],
+ 'name' => (string)$p['name'],
+ 'description' => zjmf_render_description((string)($p['description'] ?? '')),
+ 'currency' => (string)($p['currency'] ?? ''),
+ 'cycles' => $cycles,
+ ];
+ }
+ zjmf_json('ok', ['list' => $list]);
+});
+
+// 可用支付方式(account SPA 下单页用)
+mnbt_register_route('GET', '/reserve/api/methods', function ($params, $ctx) {
+ $methods = function_exists('mnbt_get_enabled_payment_methods')
+ ? mnbt_get_enabled_payment_methods() : [];
+ zjmf_json('ok', ['methods' => $methods]);
+});
+
+// 我的主机(account SPA,脱敏后返回)
+mnbt_register_route('GET', '/reserve/api/hosts', function ($params, $ctx) {
+ $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
+ if (!$user) {
+ zjmf_json('not_login', ['logged_in' => false]);
+ return;
+ }
+ $hosts = [];
+ foreach (zjmf_host_list_by_user((int)$user['id']) as $h) {
+ $hosts[] = [
+ 'id' => (int)$h['id'],
+ 'up_host_id' => (int)$h['up_host_id'],
+ 'name' => (string)$h['name'],
+ 'username' => (string)$h['username'],
+ 'cycle' => (string)$h['cycle'],
+ 'status' => (string)$h['status'],
+ 'renew_date' => (string)$h['renew_date'],
+ 'created_at' => (string)$h['created_at'],
+ 'updated_at' => (string)$h['updated_at'],
+ ];
+ }
+ zjmf_json('ok', ['logged_in' => true, 'hosts' => $hosts]);
+});
+
+// 我的订单(account SPA,分页)
+mnbt_register_route('GET', '/reserve/api/orders', function ($params, $ctx) {
+ $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
+ if (!$user) {
+ zjmf_json('not_login', ['logged_in' => false]);
+ return;
+ }
+ $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
+ $per = isset($_GET['per_page']) ? max(1, min(100, (int)$_GET['per_page'])) : 15;
+ $orders = zjmf_order_list_by_user((int)$user['id'], $page, $per);
+ zjmf_json('ok', ['logged_in' => true, 'orders' => $orders]);
+});
+
// 我的订单
mnbt_register_route('GET', '/reserve/orders', function ($params, $ctx) {
$user = zjmf_require_user();
@@ -594,7 +738,7 @@ mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx)
zjmf_json('该主机缺少上游主机 ID,无法执行操作');
}
if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
- zjmf_json('供应商已停用,无法执行操作');
+ zjmf_json('服务已停用,无法执行操作');
}
$supplier = zjmf_supplier_get((int)$host['supplier_id']);
@@ -662,7 +806,7 @@ mnbt_register_route('GET', '/reserve/hosts/{host_id}/upgrade', function ($params
return;
}
if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
- echo '供应商已停用,无法升级';
+ echo '服务已停用,无法升级';
return;
}
$supplier = zjmf_supplier_get((int)$host['supplier_id']);
@@ -693,7 +837,7 @@ mnbt_register_route('POST', '/reserve/api/upgrade', function ($params, $ctx) {
zjmf_json('该主机缺少上游主机 ID,无法升级');
}
if (!zjmf_supplier_usable((int)$host['supplier_id'])) {
- zjmf_json('供应商已停用,无法升级');
+ zjmf_json('服务已停用,无法升级');
}
$supplier = zjmf_supplier_get((int)$host['supplier_id']);
diff --git a/app_plugins/zjmfmanager_reserve/lib/upstream.php b/app_plugins/zjmfmanager_reserve/lib/upstream.php
index 539e951..ccb825f 100644
--- a/app_plugins/zjmfmanager_reserve/lib/upstream.php
+++ b/app_plugins/zjmfmanager_reserve/lib/upstream.php
@@ -100,14 +100,33 @@ class ZjmfUpstream
}
try {
$upId = (int)$upProductId;
- $detail = $client->productDetail($upId, 'agent');
- $prod = $detail['data']['product'] ?? ($detail['data'] ?? []);
- $item = [
- 'id' => $upId,
- 'name' => (string)($prod['name'] ?? ''),
- 'description' => (string)($prod['description'] ?? ''),
- ];
- $currency = (string)($detail['data']['currency_code'] ?? '');
+ // 优先从列表取真实条目(含 billingcycle/price 等周期信息),失败再退回详情
+ $item = null;
+ $currency = '';
+ try {
+ $res = $client->productList();
+ if (($res['status'] ?? 0) == 200 && !empty($res['data']['list'])) {
+ $currency = (string)($res['data']['currency_code'] ?? '');
+ foreach ($res['data']['list'] as $row) {
+ if ((int)($row['id'] ?? 0) === $upId) {
+ $item = $row;
+ break;
+ }
+ }
+ }
+ } catch (CubeFinanceException $e) {
+ // 列表失败忽略,退回详情
+ }
+ if (!$item) {
+ $detail = $client->productDetail($upId, 'agent');
+ $prod = $detail['data']['product'] ?? ($detail['data'] ?? []);
+ $item = [
+ '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
);
@@ -194,8 +213,9 @@ class ZjmfUpstream
global $DB, $date;
$now = $date ?: date('Y-m-d H:i:s');
- // 代理价(详情接口)
+ // 代理价(详情接口,失败回退列表项 price)
$agentCents = 0;
+ $detail = null;
try {
$detail = $client->productDetail($upId, 'agent');
$prod = $detail['data']['product'] ?? ($detail['data'] ?? []);
@@ -203,27 +223,51 @@ class ZjmfUpstream
} catch (CubeFinanceException $e) {
// 详情失败不致命,仅代理价缺失
}
+ if ($agentCents <= 0) {
+ $agentCents = self::itemPrice($item);
+ }
- // 各周期试算价
- $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,
- 'name' => $cfg['name'],
- 'agent_price_cents' => $price,
- ];
+ // 各周期价:优先读上游返回的 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;
}
- } catch (CubeFinanceException $e) {
- continue;
}
}
- $cyclesJson = json_encode($cycles, JSON_UNESCAPED_UNICODE);
+ $cyclesJson = json_encode(array_values($cycles), JSON_UNESCAPED_UNICODE);
$existing = zjmf_product_get_by_up($supplierId, $upId);
+ if ($existing) {
+ // 同步不覆盖管理员手动设置的售价(override)
+ $oldCycles = zjmf_product_cycles($existing);
+ $changed = false;
+ foreach ($cycles as $k => &$cfg) {
+ if (isset($oldCycles[$k]['override']) && (int)$oldCycles[$k]['override'] > 0) {
+ $cfg['override'] = (int)$oldCycles[$k]['override'];
+ $cfg['price_cents'] = (int)$oldCycles[$k]['override'];
+ $changed = true;
+ }
+ }
+ unset($cfg);
+ if ($changed) {
+ $cyclesJson = json_encode(array_values($cycles), JSON_UNESCAPED_UNICODE);
+ }
+ }
if ($existing) {
// 重复同步仅刷新价格/周期,不覆盖本地已修改的名称与简介
$ok = $DB->query_prepare(
@@ -250,7 +294,7 @@ class ZjmfUpstream
$supplierId,
$upId,
(string)($item['name'] ?? ''),
- (string)($item['description'] ?? ''),
+ zjmf_render_description((string)($item['description'] ?? '')),
$currency,
$agentCents,
$cyclesJson,
@@ -576,6 +620,125 @@ class ZjmfUpstream
return 0;
}
+ /**
+ * 从列表项提取各周期价格(分)。
+ * 魔方财务列表项为「单周期」结构:billingcycle + price + billingcycle_zh(cycle 只是标签字符串)。
+ * 兼容完整周期结构(cycle/pricing 为数组或映射),解析失败返回空数组。
+ */
+ protected static function parseItemCycles($item)
+ {
+ if (!is_array($item)) {
+ return [];
+ }
+ // 单周期结构:billingcycle + price + billingcycle_zh
+ $key = (string)($item['billingcycle'] ?? '');
+ if ($key !== '') {
+ $cents = self::toCents(self::pickPrice($item));
+ if ($cents > 0) {
+ $entry = self::cycleEntry($key, $cents, (string)($item['billingcycle_zh'] ?? ''));
+ 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;
+ }
+ if (is_array($raw) && $raw !== []) {
+ return self::parseCycleMap($raw);
+ }
+ return [];
+ }
+
+ /**
+ * 从商品详情响应提取各周期价格(分)。
+ * 魔方财务详情接口将各周期价格放在 data.product.cycle(结构与列表项一致)。
+ */
+ protected static function parseDetailCycles($detail)
+ {
+ $data = $detail['data'] ?? [];
+ if (!is_array($data)) {
+ return [];
+ }
+ $prod = is_array($data['product'] ?? null) ? $data['product'] : $data;
+ $raw = $prod['cycle'] ?? $data['cycle'] ?? null;
+ if (is_string($raw)) {
+ $decoded = json_decode($raw, true);
+ $raw = is_array($decoded) ? $decoded : null;
+ }
+ if (!is_array($raw) || $raw === []) {
+ return [];
+ }
+ return self::parseCycleMap($raw);
+ }
+
+ /** 构建单个周期条目(键归一化 + 名称兜底)。 */
+ protected static function cycleEntry($key, $cents, $name = '')
+ {
+ $norm = self::normalizeCycleKey($key);
+ $known = zjmf_cycles();
+ return [
+ 'cycle' => $norm,
+ 'name' => $name !== '' ? $name : ($known[$norm]['name'] ?? $norm),
+ 'agent_price_cents' => max(0, (int)$cents),
+ ];
+ }
+
+ /** 周期键归一化:忽略大小写匹配内置周期(monthly → Monthly)。 */
+ protected static function normalizeCycleKey($key)
+ {
+ $key = (string)$key;
+ if ($key === '') {
+ return '';
+ }
+ foreach (array_keys(zjmf_cycles()) as $k) {
+ if (strcasecmp($k, $key) === 0) {
+ return $k;
+ }
+ }
+ return $key;
+ }
+
+ /** 解析周期映射/数组,返回 ['Monthly' => ['cycle','name','agent_price_cents']]。 */
+ protected static function parseCycleMap($raw)
+ {
+ $out = [];
+ // 数组形式:[{billingcycle: 'monthly', billingcycle_zh: '月', price: '25.00'}, ...]
+ if (isset($raw[0]) && is_array($raw[0])) {
+ foreach ($raw as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $key = (string)($item['billingcycle'] ?? $item['cycle'] ?? '');
+ if ($key === '') {
+ continue;
+ }
+ $cents = self::toCents(self::pickPrice($item));
+ if ($cents <= 0) {
+ continue;
+ }
+ $name = (string)($item['name'] ?? $item['billingcycle_zh'] ?? '');
+ $out[self::normalizeCycleKey($key)] = self::cycleEntry($key, $cents, $name);
+ }
+ } else {
+ // 映射形式:{'monthly': '25.00' 或 {price: '25.00'}}
+ foreach ($raw as $key => $v) {
+ $key = (string)$key;
+ if ($key === '') {
+ continue;
+ }
+ $cents = self::toCents(is_array($v) ? self::pickPrice($v) : $v);
+ if ($cents <= 0) {
+ continue;
+ }
+ $name = is_array($v) ? (string)($v['name'] ?? $v['billingcycle_zh'] ?? '') : '';
+ $out[self::normalizeCycleKey($key)] = self::cycleEntry($key, $cents, $name);
+ }
+ }
+ return $out;
+ }
+
/** 金额(元)→ 分。 */
protected static function toCents($val)
{
diff --git a/app_plugins/zjmfmanager_reserve/lib/zjmf.php b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
index e4c89bc..1694c7b 100644
--- a/app_plugins/zjmfmanager_reserve/lib/zjmf.php
+++ b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
@@ -201,6 +201,35 @@ function zjmf_cycles()
];
}
+/**
+ * 渲染商品简介为规范的展示 HTML。
+ * 上游常见 `<li>CPU:4核</li> <li>内存:4G</li>...` 格式:
+ * 解码实体 → 压缩标签间空白 → 外层包裹 <ul> 渲染成列表。
+ * 非 <li> 内容(含管理员手写 HTML)仅解码实体后原样输出。
+ */
+function zjmf_render_description($raw)
+{
+ $html = (string)$raw;
+ if ($html === '') {
+ return '';
+ }
+ // 解码实体直到稳定(兼容单/双重编码)
+ $i = 0;
+ do {
+ $prev = $html;
+ $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $i++;
+ } while ($html !== $prev && $i < 3);
+ if (stripos($html, '<li') === false) {
+ return $html;
+ }
+ $html = preg_replace('/>\s+</', '><', $html);
+ if (stripos($html, '<ul') === false) {
+ $html = '<ul>' . $html . '</ul>';
+ }
+ return $html;
+}
+
/* ============================================================
* 供应商管理
* ============================================================ */
@@ -301,8 +330,7 @@ function zjmf_product_list_active()
{
global $DB;
return $DB->get_all_prepare(
- "SELECT p.*, s.name AS supplier_name
- FROM MN_plugin_zjmf_product p
+ "SELECT p.* FROM MN_plugin_zjmf_product p
LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = p.supplier_id
WHERE p.status=1 AND s.status=1
ORDER BY s.sort ASC, p.sort ASC, p.id ASC"
@@ -321,7 +349,7 @@ function zjmf_product_list_all()
) ?: [];
}
-/** 解析商品周期 JSON,返回 ['cycle' => ['name'=>, 'price_cents'=>]]。 */
+/** 解析商品周期 JSON,返回 ['cycle' => ['name'=>, 'price_cents'=>, 'override'=>]]。 */
function zjmf_product_cycles($product)
{
$raw = isset($product['cycles']) ? json_decode($product['cycles'], true) : null;
@@ -329,14 +357,31 @@ function zjmf_product_cycles($product)
return [];
}
$map = [];
+ // 旧版扁平格式:["Monthly","月",2500]
+ if (isset($raw[0]) && !is_array($raw[0])) {
+ if ($raw[0] !== '' && isset($raw[2])) {
+ $cycle = (string)$raw[0];
+ $map[$cycle] = [
+ 'cycle' => $cycle,
+ 'name' => (string)$raw[1],
+ 'price_cents' => (int)$raw[2],
+ 'agent_price_cents' => (int)$raw[2],
+ 'override' => 0,
+ ];
+ }
+ return $map;
+ }
foreach ($raw as $item) {
$cycle = (string)($item['cycle'] ?? '');
if ($cycle === '') {
continue;
}
$map[$cycle] = [
- 'name' => (string)($item['name'] ?? $cycle),
- 'price_cents' => (int)($item['price_cents'] ?? 0),
+ 'cycle' => $cycle,
+ 'name' => (string)($item['name'] ?? $cycle),
+ 'price_cents' => (int)($item['price_cents'] ?? 0),
+ 'agent_price_cents' => (int)($item['agent_price_cents'] ?? 0),
+ 'override' => (int)($item['override'] ?? 0),
];
}
return $map;
@@ -388,12 +433,16 @@ function zjmf_product_recalc_price($product_id)
return;
}
foreach ($cycles as $cycle => &$cfg) {
- // agent_price 保存的是该周期对应的上游价(分)
- $cfg['price_cents'] = zjmf_calc_price(
- $cfg['agent_price_cents'] ?? 0,
- $markupType,
- $markupValue
- );
+ // 管理员手动设置过售价(override>0)时保持不动,否则按加价规则重算
+ if ((int)($cfg['override'] ?? 0) > 0) {
+ $cfg['price_cents'] = (int)$cfg['override'];
+ } else {
+ $cfg['price_cents'] = zjmf_calc_price(
+ $cfg['agent_price_cents'] ?? 0,
+ $markupType,
+ $markupValue
+ );
+ }
}
unset($cfg);
$now = $date ?: date('Y-m-d H:i:s');
@@ -441,9 +490,7 @@ function zjmf_order_list_by_user($user_id, $page = 1, $per_page = 20)
);
$total = $count_row ? (int)$count_row['cnt'] : 0;
$list = $DB->get_all_prepare(
- "SELECT o.*, s.name AS supplier_name
- FROM MN_plugin_zjmf_order o
- LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = o.supplier_id
+ "SELECT o.* FROM MN_plugin_zjmf_order o
WHERE o.user_id=? ORDER BY o.id DESC LIMIT {$offset},{$per_page}",
[$user_id]
) ?: [];
@@ -674,9 +721,7 @@ function zjmf_host_get_by_user($user_id, $host_id)
{
global $DB;
return $DB->get_row_prepare(
- "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
+ "SELECT h.* FROM MN_plugin_zjmf_host h
WHERE h.id=? AND h.user_id=? LIMIT 1",
[(int)$host_id, (int)$user_id]
) ?: null;
@@ -697,9 +742,7 @@ function zjmf_host_list_by_user($user_id)
{
global $DB;
return $DB->get_all_prepare(
- "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
+ "SELECT h.* FROM MN_plugin_zjmf_host h
WHERE h.user_id=? ORDER BY h.id DESC",
[(int)$user_id]
) ?: [];
diff --git a/app_plugins/zjmfmanager_reserve/views/admin/products.php b/app_plugins/zjmfmanager_reserve/views/admin/products.php
index b3be28b..b82f5a8 100644
--- a/app_plugins/zjmfmanager_reserve/views/admin/products.php
+++ b/app_plugins/zjmfmanager_reserve/views/admin/products.php
@@ -174,6 +174,14 @@ function zjmf_admin_markup_label($product)
<option value="0">下架</option>
</select>
</div>
+ <div class="col-md-12 mb-2">
+ <label class="small text-muted">周期售价(元)</label>
+ <div id="zjf-edit-cycles" class="border rounded p-2 bg-light"></div>
+ <div class="small text-muted mt-1">
+ 留空表示按加价规则计算;填写后该周期按填写值定价,重新同步不覆盖手动定价。
+ 无周期数据时可直接填写售价生成周期。
+ </div>
+ </div>
<div class="col-md-12 mb-2">
<label class="small text-muted">商品简介(支持 HTML)</label>
<textarea id="zjf-edit-desc" class="form-control form-control-sm" rows="4"
@@ -184,6 +192,7 @@ function zjmf_admin_markup_label($product)
留空加价数值表示使用所属供应商的加价规则;重新同步上游仅更新价格,不覆盖本地名称与简介。
</div>
<button type="button" class="btn btn-sm btn-primary" id="zjf-edit-save">保存</button>
+ <button type="button" class="btn btn-sm btn-outline-primary" id="zjf-edit-save-cycles">保存售价</button>
<button type="button" class="btn btn-sm btn-secondary" id="zjf-edit-cancel">取消</button>
</div>
@@ -235,6 +244,8 @@ function zjmf_admin_markup_label($product)
data-id="<?= (int)$p['id'] ?>"
data-name="<?= htmlspecialchars($p['name'], ENT_QUOTES) ?>"
data-desc="<?= htmlspecialchars($p['description'] ?? '', ENT_QUOTES) ?>"
+ data-cycles="<?= htmlspecialchars((string)($p['cycles'] ?? ''), ENT_QUOTES) ?>"
+ data-agent="<?= (int)$p['agent_price_cents'] ?>"
data-type="<?= (int)$p['markup_type'] ?>"
data-value="<?= (int)$p['markup_value'] ?>"
data-sort="<?= (int)$p['sort'] ?>"
@@ -422,6 +433,33 @@ function zjmf_admin_markup_label($product)
var editSort = document.getElementById('zjf-edit-sort');
var editStatus = document.getElementById('zjf-edit-status');
+ function renderCyclesEditor(raw, agent) {
+ var box = document.getElementById('zjf-edit-cycles');
+ box.innerHTML = '';
+ var cycles = [];
+ try { cycles = JSON.parse(raw || '[]'); } catch (e) { cycles = []; }
+ cycles = (Array.isArray(cycles) ? cycles : []).filter(function (c) {
+ return c && typeof c === 'object' && c.cycle;
+ });
+ if (!cycles.length) {
+ // 无周期数据(如旧版坏数据/未同步),给默认行便于直接定价
+ cycles = [{cycle: 'Monthly', name: '月付', price_cents: 0, agent_price_cents: agent || 0}];
+ }
+ cycles.forEach(function (c) {
+ var row = document.createElement('div');
+ row.className = 'd-flex align-items-center mb-1';
+ row.style.maxWidth = '420px';
+ row.innerHTML =
+ '<span class="mr-2 small" style="min-width:56px">' + esc(c.name || c.cycle) + '</span>'
+ + '<input type="number" step="0.01" min="0" class="form-control form-control-sm zjf-cycle-input"'
+ + ' data-cycle="' + esc(c.cycle) + '" value="'
+ + (c.price_cents > 0 ? (c.price_cents / 100).toFixed(2) : '') + '">'
+ + '<span class="ml-2 small text-muted" style="min-width:110px">上游 '
+ + (c.agent_price_cents > 0 ? '¥' + (c.agent_price_cents / 100).toFixed(2) : '-') + '</span>';
+ box.appendChild(row);
+ });
+ }
+
document.querySelectorAll('.zjf-edit').forEach(function (btn) {
btn.addEventListener('click', function () {
syncWrap.style.display = 'none';
@@ -433,6 +471,8 @@ function zjmf_admin_markup_label($product)
editValue.value = btn.getAttribute('data-value');
editSort.value = btn.getAttribute('data-sort');
editStatus.value = btn.getAttribute('data-status');
+ renderCyclesEditor(btn.getAttribute('data-cycles'),
+ parseInt(btn.getAttribute('data-agent'), 10) || 0);
editWrap.style.display = 'block';
editWrap.scrollIntoView({behavior: 'smooth', block: 'start'});
});
@@ -458,6 +498,24 @@ function zjmf_admin_markup_label($product)
}, true);
});
+ document.getElementById('zjf-edit-save-cycles').addEventListener('click', function () {
+ var id = parseInt(editId.value, 10);
+ if (!id) return;
+ var overrides = {};
+ document.querySelectorAll('.zjf-cycle-input').forEach(function (inp) {
+ var v = inp.value;
+ if (v !== '' && !isNaN(parseFloat(v))) {
+ overrides[inp.getAttribute('data-cycle')] = parseFloat(v);
+ }
+ });
+ if (!Object.keys(overrides).length) { alert('请至少填写一个周期的售价'); return; }
+ $.post('ajax.php', {
+ gn: 'p_zjmf_admin_save_cycles',
+ id: id,
+ overrides: overrides
+ }, function (r) { notify(res(r), true); });
+ });
+
document.querySelectorAll('.zjf-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
post({gn: 'p_zjmf_admin_toggle_product', id: btn.getAttribute('data-id')}, true);
diff --git a/app_plugins/zjmfmanager_reserve/views/order.php b/app_plugins/zjmfmanager_reserve/views/order.php
index 3f7a2ea..8a2381f 100644
--- a/app_plugins/zjmfmanager_reserve/views/order.php
+++ b/app_plugins/zjmfmanager_reserve/views/order.php
@@ -19,7 +19,7 @@ ob_start();
<a class="layui-btn layui-btn-xs layui-btn-primary" href="<?= zjmf_url('reserve/shop') ?>">返回商品列表</a>
</div>
- <div class="zj-desc"><?= $product['description'] ?></div>
+ <div class="zj-desc"><?= zjmf_render_description($product['description']) ?></div>
<form class="zj-order-form" id="zjf-order-form">
<div class="layui-form-item">
diff --git a/app_plugins/zjmfmanager_reserve/views/shop.php b/app_plugins/zjmfmanager_reserve/views/shop.php
index 4d8b588..8200bd8 100644
--- a/app_plugins/zjmfmanager_reserve/views/shop.php
+++ b/app_plugins/zjmfmanager_reserve/views/shop.php
@@ -49,7 +49,7 @@ ob_start();
<span class="zj-tag"><?= htmlspecialchars($product['currency']) ?></span>
<?php endif; ?>
</div>
- <div class="zj-card-desc"><?= $product['description'] ?></div>
+ <div class="zj-card-desc"><?= zjmf_render_description($product['description']) ?></div>
<div class="zj-price">
<?php $cycles = zjmf_product_cycles($product); ?>
<?php foreach ($cycles as $cycle => $cfg): ?>
diff --git a/templates/tdesign/account/_spa_boot.php b/templates/tdesign/account/_spa_boot.php
index 2d9db1a..62504c7 100644
--- a/templates/tdesign/account/_spa_boot.php
+++ b/templates/tdesign/account/_spa_boot.php
@@ -47,6 +47,7 @@ $boot = [
'hosting_shop' => function_exists('mnbt_plugin_enabled') ? mnbt_plugin_enabled('hosting_shop') : false,
'docker_shop' => function_exists('mnbt_plugin_enabled') ? mnbt_plugin_enabled('docker_shop') : false,
'realname' => function_exists('mnbt_plugin_enabled') ? mnbt_plugin_enabled('realname') : false,
+ 'zjmf' => function_exists('mnbt_plugin_enabled') ? mnbt_plugin_enabled('zjmfmanager_reserve') : false,
],
// 主机管理面板入口(核心 user scope)
'panelUrl' => $td_base . '/user/',
diff --git a/templates/tdesign/home/index.php b/templates/tdesign/home/index.php
index 92143b3..3d07845 100644
--- a/templates/tdesign/home/index.php
+++ b/templates/tdesign/home/index.php
@@ -61,6 +61,7 @@ $boot = [
'hasUser' => !empty($has_user),
'hasSite' => !empty($has_site),
'hasDocker' => !empty($has_docker),
+ 'hasZjmf' => function_exists('mnbt_plugin_enabled') ? mnbt_plugin_enabled('zjmfmanager_reserve') : false,
'plans' => $plans ?? [],
'blocks' => $blocks ?? [],
'base' => function_exists('mnbt_home_base') ? mnbt_home_base() : '',
diff --git a/templates/tdesign/spa/src/account/api/plugins.js b/templates/tdesign/spa/src/account/api/plugins.js
index a2f5f58..7b9a215 100644
--- a/templates/tdesign/spa/src/account/api/plugins.js
+++ b/templates/tdesign/spa/src/account/api/plugins.js
@@ -150,3 +150,37 @@ export function dockerConsoleUrl() {
const boot = window.__TD_BOOT__ || {}
return boot.dockerUrl || ''
}
+
+/* ============================================================
+ * zjmfmanager_reserve 插件(云服务器/魔方财务分销)
+ * ============================================================ */
+
+/** 上架商品列表(含各周期价格,游客可访问) */
+export function getZjmfProducts() {
+ return apiGet('/reserve/api/products')
+}
+
+/** 我的主机(已脱敏,不含密码) */
+export function getZjmfHosts() {
+ return apiGet('/reserve/api/hosts')
+}
+
+/** 我的订单(分页) */
+export function getZjmfOrders(page = 1, perPage = 15) {
+ return apiGet('/reserve/api/orders', { page, per_page: perPage })
+}
+
+/** 可用支付方式(zjmf 下单用,含余额支付等全部方式) */
+export function getZjmfMethods() {
+ return apiGet('/reserve/api/methods')
+}
+
+/** 创建购买订单:product_id + cycle + type → 返回支付 HTML */
+export function createZjmfOrder(productId, cycle, type) {
+ return apiPost('/reserve/api/create_order', { product_id: productId, cycle, type })
+}
+
+/** 主机操作:开机/关机/重启/重置密码/重装(重置密码需传 password) */
+export function zjmfHostAction(hostId, action, password = '') {
+ return apiPost('/reserve/api/host_action', { host_id: hostId, action, password })
+}
diff --git a/templates/tdesign/spa/src/account/layouts/AccountLayout.vue b/templates/tdesign/spa/src/account/layouts/AccountLayout.vue
index bda2476..4f589ec 100644
--- a/templates/tdesign/spa/src/account/layouts/AccountLayout.vue
+++ b/templates/tdesign/spa/src/account/layouts/AccountLayout.vue
@@ -154,6 +154,39 @@
</li>
</ul>
</template>
+
+ <!-- 云服务器(zjmfmanager_reserve 插件) -->
+ <template v-if="hasZjmf">
+ <div class="td-side-group" v-show="!sidebarCollapsed || isMobile">
+ <div class="td-side-group-label">
+ <i class="mdi mdi-cloud-outline"></i>
+ <span>云服务器</span>
+ </div>
+ </div>
+ <ul class="td-side-menu">
+ <li class="td-side-item">
+ <router-link to="/zjmf-shop" custom v-slot="{ navigate, isActive }">
+ <a href="javascript:;" :class="{ active: isActive }" @click="navigate">
+ <i class="mdi mdi-cart"></i><span>商品选购</span>
+ </a>
+ </router-link>
+ </li>
+ <li class="td-side-item">
+ <router-link to="/zjmf-assets" custom v-slot="{ navigate, isActive }">
+ <a href="javascript:;" :class="{ active: isActive }" @click="navigate">
+ <i class="mdi mdi-server"></i><span>我的主机</span>
+ </a>
+ </router-link>
+ </li>
+ <li class="td-side-item">
+ <router-link to="/zjmf-orders" custom v-slot="{ navigate, isActive }">
+ <a href="javascript:;" :class="{ active: isActive }" @click="navigate">
+ <i class="mdi mdi-receipt"></i><span>我的订单</span>
+ </a>
+ </router-link>
+ </li>
+ </ul>
+ </template>
</div>
<!-- 底部用户卡 -->
@@ -242,6 +275,7 @@ const hasBalance = pluginEnabled('balance')
const hasShop = pluginEnabled('hosting_shop')
const hasDocker = pluginEnabled('docker_shop')
const hasRealname = pluginEnabled('realname')
+const hasZjmf = pluginEnabled('zjmf')
const sidebarCollapsed = ref(false)
const mobileOpen = ref(false)
@@ -261,6 +295,7 @@ const userMenuOptions = computed(() => {
if (hasBalance) list.push({ content: '余额中心', value: 'balance' })
if (hasShop) list.push({ content: '主机商城', value: 'shop' })
if (hasDocker) list.push({ content: 'Docker 商城', value: 'docker-shop' })
+ if (hasZjmf) list.push({ content: '云服务器', value: 'zjmf-shop' })
list.push({ content: '退出登录', value: 'logout', theme: 'error' })
return list
})
@@ -299,12 +334,22 @@ function onLogout() {
})
}
+/** zjmfmanager_reserve 插件独立页面 URL(主机详情等仍走 P2 路由) */
+function reserveUrl(path) {
+ const base = boot.routeBase || '/index.php?_r='
+ return base + 'reserve/' + String(path).replace(/^\/+/, '')
+}
+
function onUserMenuClick(item) {
const v = item?.value
if (v === 'logout') {
onLogout()
return
}
+ if (v === 'zjmf-shop') {
+ router.push('/zjmf-shop')
+ return
+ }
const targets = {
dashboard: '/dashboard',
profile: '/profile',
diff --git a/templates/tdesign/spa/src/account/router/index.js b/templates/tdesign/spa/src/account/router/index.js
index 2a79ef9..05fcbbd 100644
--- a/templates/tdesign/spa/src/account/router/index.js
+++ b/templates/tdesign/spa/src/account/router/index.js
@@ -95,6 +95,24 @@ const routes = [
component: () => import('@/account/views/DockerOrdersView.vue'),
meta: { title: 'Docker 订单', requiresAuth: true },
},
+ {
+ path: 'zjmf-shop',
+ name: 'zjmf-shop',
+ component: () => import('@/account/views/ZjmfShopView.vue'),
+ meta: { title: '云服务器选购', requiresAuth: true },
+ },
+ {
+ path: 'zjmf-assets',
+ name: 'zjmf-assets',
+ component: () => import('@/account/views/ZjmfAssetsView.vue'),
+ meta: { title: '我的云服务器', requiresAuth: true },
+ },
+ {
+ path: 'zjmf-orders',
+ name: 'zjmf-orders',
+ component: () => import('@/account/views/ZjmfOrdersView.vue'),
+ meta: { title: '云服务器订单', requiresAuth: true },
+ },
],
},
{
diff --git a/templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue b/templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue
new file mode 100644
index 0000000..f194e68
--- /dev/null
+++ b/templates/tdesign/spa/src/account/views/ZjmfAssetsView.vue
@@ -0,0 +1,309 @@
+<template>
+ <div class="td-page">
+ <div class="td-page-head">
+ <div>
+ <h3 class="td-page-title"><i class="mdi mdi-server"></i> 我的云服务器</h3>
+ <p class="td-page-subtitle">查看已开通的主机,可执行开机/关机/重启/重置密码,管理详情跳转独立页面</p>
+ </div>
+ <t-button variant="outline" @click="router.push('/zjmf-shop')">
+ <i class="mdi mdi-cart"></i> 前往选购
+ </t-button>
+ </div>
+
+ <div v-if="hosts.length" class="zjmf-asset-grid">
+ <div class="zjmf-asset td-card" v-for="h in hosts" :key="h.id">
+ <div class="zjmf-asset-head">
+ <div class="zjmf-asset-icon"><i class="mdi mdi-cloud-outline"></i></div>
+ <div class="zjmf-asset-info">
+ <strong>{{ h.name }}</strong>
+ <span class="zjmf-asset-node">{{ cycleText(h.cycle) }}</span>
+ </div>
+ <t-tag :theme="statusTheme(h.status)" variant="light">
+ {{ statusText(h.status) }}
+ </t-tag>
+ </div>
+
+ <div class="zjmf-asset-meta">
+ <div class="meta-item">
+ <span>主机账号</span>
+ <b class="mono">{{ h.username || '—' }}</b>
+ </div>
+ <div class="meta-item">
+ <span>上游主机 ID</span>
+ <b>{{ h.up_host_id > 0 ? h.up_host_id : '—' }}</b>
+ </div>
+ <div class="meta-item">
+ <span>到期时间</span>
+ <b :class="{ expired: isExpired(h) }">{{ h.renew_date || '—' }}</b>
+ </div>
+ <div class="meta-item">
+ <span>开通时间</span>
+ <b>{{ h.created_at || '—' }}</b>
+ </div>
+ </div>
+
+ <div class="zjmf-asset-actions">
+ <t-button
+ theme="primary"
+ variant="outline"
+ size="small"
+ :href="reserveUrl('hosts/' + h.id)"
+ target="_blank"
+ rel="noopener"
+ >
+ <i class="mdi mdi-cog-outline"></i> 管理详情
+ </t-button>
+ <t-button
+ variant="outline"
+ size="small"
+ theme="success"
+ :disabled="!canAction(h, 'on')"
+ :loading="h._acting === 'on'"
+ @click="onAction(h, 'on')"
+ >
+ <i class="mdi mdi-play"></i> 开机
+ </t-button>
+ <t-button
+ variant="outline"
+ size="small"
+ theme="warning"
+ :disabled="!canAction(h, 'off')"
+ :loading="h._acting === 'off'"
+ @click="onAction(h, 'off')"
+ >
+ <i class="mdi mdi-stop"></i> 关机
+ </t-button>
+ <t-button
+ variant="outline"
+ size="small"
+ :disabled="!canAction(h, 'reboot')"
+ :loading="h._acting === 'reboot'"
+ @click="onAction(h, 'reboot')"
+ >
+ <i class="mdi mdi-restart"></i> 重启
+ </t-button>
+ <t-button
+ variant="outline"
+ size="small"
+ :loading="h._acting === 'reset_password'"
+ @click="onResetPassword(h)"
+ >
+ <i class="mdi mdi-key-change"></i> 重置密码
+ </t-button>
+ </div>
+ </div>
+ </div>
+
+ <t-empty v-else description="暂无云服务器,前往商城选购" style="padding: 60px 0">
+ <t-button theme="primary" @click="router.push('/zjmf-shop')">去选购商品</t-button>
+ </t-empty>
+
+ <!-- 重置密码对话框 -->
+ <t-dialog
+ v-model:visible="pwdVisible"
+ :header="'重置密码:' + (currentHost?.name || '')"
+ :confirm-btn="{ content: '确认重置', theme: 'danger', loading: pwdSubmitting }"
+ :cancel-btn="'取消'"
+ :on-confirm="onConfirmPwd"
+ >
+ <p style="font-size: 13px; color: var(--td-text-secondary); margin: 0 0 12px">
+ 新密码将立即生效,请妥善保存。
+ </p>
+ <t-input v-model="newPassword" type="password" placeholder="输入新密码" clearable />
+ </t-dialog>
+ </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
+import { getZjmfHosts, zjmfHostAction } from '@/account/api/plugins'
+
+const router = useRouter()
+const boot = window.__TD_BOOT__ || {}
+
+const hosts = ref([])
+
+// 重置密码
+const pwdVisible = ref(false)
+const currentHost = ref(null)
+const newPassword = ref('')
+const pwdSubmitting = ref(false)
+
+function statusText(status) {
+ return { active: '运行中', suspend: '已暂停', unknown: '未知' }[status] || status
+}
+
+function cycleText(cycle) {
+ const map = {
+ Monthly: '月付',
+ Quarterly: '季付',
+ SemiAnnually: '半年付',
+ Annually: '年付',
+ Biennially: '两年付',
+ Triennially: '三年付',
+ }
+ return map[cycle] || cycle || ''
+}
+
+function statusTheme(status) {
+ if (status === 'active') return 'success'
+ if (status === 'suspend') return 'warning'
+ return 'default'
+}
+
+function isExpired(h) {
+ return !!h.renew_date && h.renew_date < new Date().toISOString().slice(0, 10)
+}
+
+/** 操作可用性:运行中才能关机/重启,已暂停/未知才能开机 */
+function canAction(h, action) {
+ if (h.up_host_id <= 0) return false
+ const active = h.status === 'active'
+ if (action === 'on') return !active
+ if (action === 'off') return active
+ if (action === 'reboot') return active
+ return true
+}
+
+function reserveUrl(path) {
+ const base = boot.routeBase || '/index.php?_r='
+ return base + 'reserve/' + String(path).replace(/^\/+/, '')
+}
+
+function onAction(h, action) {
+ const labels = { on: '开机', off: '关机', reboot: '重启' }
+ const dialog = DialogPlugin.confirm({
+ header: labels[action],
+ body: `确定要执行「${labels[action]}」操作吗?`,
+ confirmBtn: { content: '确认', theme: action === 'off' ? 'danger' : 'primary' },
+ onConfirm: async () => {
+ dialog.destroy()
+ h._acting = action
+ const res = await zjmfHostAction(h.id, action)
+ h._acting = ''
+ if (!res.ok) {
+ MessagePlugin.error(res.message || '操作失败')
+ return
+ }
+ MessagePlugin.success('操作成功')
+ },
+ onClose: () => dialog.destroy(),
+ })
+}
+
+function onResetPassword(h) {
+ currentHost.value = h
+ newPassword.value = ''
+ pwdVisible.value = true
+}
+
+async function onConfirmPwd() {
+ const pwd = newPassword.value.trim()
+ if (!pwd) {
+ MessagePlugin.warning('请输入新密码')
+ return false
+ }
+ pwdSubmitting.value = true
+ const res = await zjmfHostAction(currentHost.value.id, 'reset_password', pwd)
+ pwdSubmitting.value = false
+ if (!res.ok) {
+ MessagePlugin.error(res.message || '重置失败')
+ return false
+ }
+ pwdVisible.value = false
+ MessagePlugin.success('密码已重置,请及时保存')
+ return true
+}
+
+async function load() {
+ const res = await getZjmfHosts()
+ if (res.ok) hosts.value = res.data.hosts || []
+}
+
+onMounted(load)
+</script>
+
+<style scoped>
+.zjmf-asset-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 16px;
+}
+.zjmf-asset {
+ padding: 18px;
+}
+.zjmf-asset-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.zjmf-asset-icon {
+ width: 42px;
+ height: 42px;
+ border-radius: 10px;
+ background: var(--td-brand-light);
+ color: var(--td-brand);
+ display: grid;
+ place-items: center;
+ font-size: 22px;
+ flex-shrink: 0;
+}
+.zjmf-asset-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+.zjmf-asset-info strong {
+ font-size: 15px;
+ color: var(--td-text);
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.zjmf-asset-node {
+ font-size: 12px;
+ color: var(--td-text-secondary);
+ margin-top: 2px;
+}
+.zjmf-asset-meta {
+ margin-top: 14px;
+ border-top: 1px dashed var(--td-border);
+ padding-top: 12px;
+}
+.meta-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 13px;
+ padding: 4px 0;
+}
+.meta-item span {
+ color: var(--td-text-placeholder);
+}
+.meta-item b {
+ color: var(--td-text);
+ font-weight: 500;
+}
+.meta-item b.expired {
+ color: var(--td-error);
+}
+.meta-item .mono {
+ font-family: Consolas, Monaco, monospace;
+}
+.zjmf-asset-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 14px;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 860px) {
+ .zjmf-asset-grid {
+ grid-template-columns: 1fr;
+ }
+}
+</style>
diff --git a/templates/tdesign/spa/src/account/views/ZjmfOrdersView.vue b/templates/tdesign/spa/src/account/views/ZjmfOrdersView.vue
new file mode 100644
index 0000000..6e7acfc
--- /dev/null
+++ b/templates/tdesign/spa/src/account/views/ZjmfOrdersView.vue
@@ -0,0 +1,189 @@
+<template>
+ <div class="td-page">
+ <div class="td-page-head">
+ <div>
+ <h3 class="td-page-title"><i class="mdi mdi-receipt"></i> 云服务器订单</h3>
+ <p class="td-page-subtitle">查看购买与升级订单记录</p>
+ </div>
+ <t-button variant="outline" @click="router.push('/zjmf-shop')">
+ <i class="mdi mdi-cart"></i> 继续购买
+ </t-button>
+ </div>
+
+ <div class="td-card" v-if="orders.length">
+ <div class="order-item" v-for="order in orders" :key="order.id">
+ <div class="order-icon" :class="'order-icon-' + orderStatus(order.status)">
+ <i class="mdi mdi-cloud-outline"></i>
+ </div>
+ <div class="order-main">
+ <div class="order-title">
+ <strong>{{ order.product_name }}</strong>
+ <span class="order-period">{{ actionLabel(order.action) }} · {{ order.cycle_name || order.cycle || '—' }}</span>
+ <t-tag :theme="statusTheme(order.status)" variant="light" size="small">
+ {{ statusText(order.status) }}
+ </t-tag>
+ </div>
+ <div class="order-meta">
+ <span>单号 {{ order.order_no || '—' }}</span>
+ <span>下单 {{ order.created_at || '—' }}</span>
+ <span v-if="order.remark" class="order-remark">备注:{{ order.remark }}</span>
+ </div>
+ </div>
+ <div class="order-amount">¥{{ centsToYuan(order.amount_cents) }}</div>
+ </div>
+
+ <div class="pager" v-if="total > perPage">
+ <t-pagination
+ v-model:current="page"
+ :page-size="perPage"
+ :total="total"
+ :show-jumper="true"
+ @change="load"
+ />
+ </div>
+ </div>
+
+ <t-empty v-else description="暂无云服务器订单" style="padding: 60px 0">
+ <t-button theme="primary" @click="router.push('/zjmf-shop')">去选购商品</t-button>
+ </t-empty>
+ </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { getZjmfOrders, centsToYuan } from '@/account/api/plugins'
+
+const router = useRouter()
+
+const ACTION_LABELS = {
+ buy: '购买',
+ upgrade_config: '配置升级',
+ upgrade_product: '产品升级',
+}
+
+const orders = ref([])
+const page = ref(1)
+const perPage = 15
+const total = ref(0)
+
+function actionLabel(action) {
+ return ACTION_LABELS[action] || action
+}
+
+function orderStatus(status) {
+ return { pending: 'pending', paid: 'paid', opened: 'opened', failed: 'failed', cancelled: 'cancelled' }[status] || 'pending'
+}
+
+function statusText(status) {
+ return { pending: '待处理', paid: '已支付', opened: '已完成', failed: '失败', cancelled: '已取消' }[status] || status
+}
+
+function statusTheme(status) {
+ if (status === 'opened') return 'success'
+ if (status === 'pending') return 'warning'
+ if (status === 'failed' || status === 'cancelled') return 'default'
+ return 'primary'
+}
+
+async function load() {
+ const res = await getZjmfOrders(page.value, perPage)
+ if (!res.ok) return
+ const data = res.data.orders || {}
+ orders.value = data.list || []
+ total.value = data.total || 0
+}
+
+onMounted(load)
+</script>
+
+<style scoped>
+.order-item {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--td-border);
+}
+.order-item:last-child {
+ border-bottom: none;
+}
+.order-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ font-size: 20px;
+ flex-shrink: 0;
+}
+.order-icon-pending {
+ background: #fff3e0;
+ color: #e37318;
+}
+.order-icon-opened {
+ background: #e8f8f0;
+ color: #2ba471;
+}
+.order-icon-paid {
+ background: #e8f3ff;
+ color: #0052d9;
+}
+.order-icon-failed,
+.order-icon-cancelled {
+ background: #f0f0f0;
+ color: #8c8c8c;
+}
+.order-main {
+ flex: 1;
+ min-width: 0;
+}
+.order-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.order-title strong {
+ font-size: 14px;
+ color: var(--td-text);
+ font-weight: 600;
+}
+.order-period {
+ font-size: 12px;
+ color: var(--td-text-secondary);
+ background: var(--td-bg);
+ padding: 1px 8px;
+ border-radius: 10px;
+}
+.order-meta {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ font-size: 12px;
+ color: var(--td-text-placeholder);
+ margin-top: 4px;
+ flex-wrap: wrap;
+}
+.order-remark {
+ color: var(--td-warning);
+}
+.order-amount {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--td-text);
+ white-space: nowrap;
+}
+.pager {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 18px;
+ border-top: 1px solid var(--td-border);
+}
+
+@media (max-width: 560px) {
+ .order-meta {
+ gap: 8px;
+ }
+}
+</style>
diff --git a/templates/tdesign/spa/src/account/views/ZjmfShopView.vue b/templates/tdesign/spa/src/account/views/ZjmfShopView.vue
new file mode 100644
index 0000000..9635e38
--- /dev/null
+++ b/templates/tdesign/spa/src/account/views/ZjmfShopView.vue
@@ -0,0 +1,279 @@
+<template>
+ <div class="td-page">
+ <div class="td-page-head">
+ <div>
+ <h3 class="td-page-title"><i class="mdi mdi-cloud-outline"></i> 云服务器选购</h3>
+ <p class="td-page-subtitle">魔方财务代理分销,即买即用,支付完成自动开通</p>
+ </div>
+ <t-button variant="outline" @click="router.push('/zjmf-assets')">
+ <i class="mdi mdi-server"></i> 我的主机
+ </t-button>
+ </div>
+
+ <div v-if="products.length" class="zjmf-grid">
+ <div class="zjmf-card td-card" v-for="p in products" :key="p.id">
+ <div class="zjmf-head">
+ <div class="zjmf-name">{{ p.name }}</div>
+ </div>
+ <div class="zjmf-desc" v-if="p.description" v-html="p.description"></div>
+ <p v-else class="zjmf-desc zjmf-desc-empty">暂无介绍</p>
+
+ <div class="zjmf-foot">
+ <div class="zjmf-price">
+ <template v-if="p.cycles.length">
+ <span class="price-num">¥{{ centsToYuan(minPrice(p)) }}</span>
+ <span class="price-unit">起</span>
+ </template>
+ <span v-else class="price-empty">未设置价格</span>
+ </div>
+ <t-button theme="primary" size="small" :disabled="!p.cycles.length" @click="openOrder(p)">
+ {{ p.cycles.length ? '立即购买' : '暂不可购' }}
+ </t-button>
+ </div>
+ </div>
+ </div>
+
+ <t-empty v-else description="暂无可购买的云服务器" style="padding: 60px 0" />
+
+ <!-- 下单对话框 -->
+ <t-dialog
+ v-model:visible="dialogVisible"
+ :header="'购买:' + (currentProduct?.name || '')"
+ :confirm-btn="{ content: '确认购买', theme: 'primary', loading: submitting }"
+ :cancel-btn="'取消'"
+ :on-confirm="onConfirmOrder"
+ >
+ <div class="order-body">
+ <div class="order-label">购买周期</div>
+ <div class="period-grid">
+ <label
+ v-for="c in currentCycles"
+ :key="c.key"
+ class="period-item"
+ :class="{ active: cycle === c.key }"
+ >
+ <input type="radio" name="cycle" :value="c.key" v-model="cycle" />
+ <span class="period-name">{{ c.name }}</span>
+ <span class="period-price">¥{{ centsToYuan(c.price_cents) }}</span>
+ </label>
+ </div>
+
+ <div class="order-label" style="margin-top: 16px">支付方式</div>
+ <div class="period-grid">
+ <label
+ v-for="m in methods"
+ :key="m.plugin + '__' + m.method"
+ class="period-item"
+ :class="{ active: payType === m.plugin + '__' + m.method }"
+ >
+ <input type="radio" name="paytype" :value="m.plugin + '__' + m.method" v-model="payType" />
+ <span class="period-name">{{ m.display_name || m.plugin + ' / ' + m.method }}</span>
+ </label>
+ </div>
+ <p v-if="!methods.length" class="no-methods">暂无可用的支付方式</p>
+ </div>
+ </t-dialog>
+ </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { MessagePlugin } from 'tdesign-vue-next'
+import {
+ getZjmfProducts, getZjmfMethods, createZjmfOrder, centsToYuan, goPay,
+} from '@/account/api/plugins'
+
+const router = useRouter()
+
+const products = ref([])
+const methods = ref([])
+
+// 下单状态
+const dialogVisible = ref(false)
+const currentProduct = ref(null)
+const cycle = ref('')
+const payType = ref('')
+const submitting = ref(false)
+
+const currentCycles = computed(() => currentProduct.value?.cycles || [])
+
+function minPrice(p) {
+ const prices = (p.cycles || []).map((c) => c.price_cents || 0).filter((v) => v > 0)
+ return prices.length ? Math.min(...prices) : 0
+}
+
+function openOrder(p) {
+ currentProduct.value = p
+ cycle.value = p.cycles?.[0]?.key || ''
+ payType.value = methods.value[0] ? methods.value[0].plugin + '__' + methods.value[0].method : ''
+ dialogVisible.value = true
+}
+
+async function onConfirmOrder() {
+ if (!currentProduct.value) return
+ if (!payType.value) {
+ MessagePlugin.warning('请选择支付方式')
+ return false
+ }
+ submitting.value = true
+ const res = await createZjmfOrder(currentProduct.value.id, cycle.value, payType.value)
+ submitting.value = false
+ if (!res.ok) {
+ MessagePlugin.error(res.message || '创建订单失败')
+ return false
+ }
+ if (!goPay(res)) {
+ MessagePlugin.success('订单创建成功')
+ dialogVisible.value = false
+ }
+ return true
+}
+
+async function load() {
+ const [productsRes, methodsRes] = await Promise.all([getZjmfProducts(), getZjmfMethods()])
+ if (productsRes.ok) products.value = productsRes.data.list || []
+ if (methodsRes.ok) methods.value = methodsRes.data.methods || []
+}
+
+onMounted(load)
+</script>
+
+<style scoped>
+.zjmf-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+}
+.zjmf-card {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ transition: transform var(--td-dur) var(--td-ease),
+ box-shadow var(--td-dur) var(--td-ease);
+}
+.zjmf-card:hover {
+ transform: translateY(-3px);
+ box-shadow: var(--td-shadow-md);
+}
+.zjmf-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.zjmf-name {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--td-text);
+}
+.zjmf-desc {
+ margin: 8px 0 16px;
+ font-size: 13px;
+ color: var(--td-text-secondary);
+ line-height: 1.6;
+ flex: 1;
+}
+/* 简介 li 列表:每项独占一行、带圆点 */
+.zjmf-desc ul {
+ margin: 0;
+ padding-left: 16px;
+}
+.zjmf-desc li {
+ display: list-item;
+ list-style: disc;
+ margin: 2px 0;
+}
+.zjmf-desc p {
+ margin: 2px 0;
+}
+.zjmf-desc-empty {
+ min-height: auto;
+}
+.zjmf-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-top: 1px solid var(--td-border);
+ padding-top: 14px;
+}
+.zjmf-price {
+ display: flex;
+ align-items: baseline;
+ gap: 2px;
+}
+.price-num {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--td-error);
+}
+.price-unit {
+ font-size: 12px;
+ color: var(--td-text-placeholder);
+}
+.price-empty {
+ font-size: 13px;
+ color: var(--td-text-placeholder);
+}
+
+/* 下单对话框 */
+.order-body {
+ padding: 4px 0 0;
+}
+.order-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--td-text);
+ margin-bottom: 10px;
+}
+.period-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+}
+.period-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 10px 12px;
+ border: 1px solid var(--td-border);
+ border-radius: 10px;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--td-text);
+ transition: border-color var(--td-dur) var(--td-ease),
+ background var(--td-dur) var(--td-ease);
+}
+.period-item input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+.period-item.active {
+ border-color: var(--td-brand);
+ background: var(--td-brand-light);
+}
+.period-name {
+ font-weight: 600;
+}
+.period-price {
+ font-size: 12px;
+ color: var(--td-brand);
+}
+.no-methods {
+ margin: 12px 0 0;
+ font-size: 13px;
+ color: var(--td-text-placeholder);
+}
+
+@media (max-width: 1080px) {
+ .zjmf-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+@media (max-width: 680px) {
+ .zjmf-grid {
+ grid-template-columns: 1fr;
+ }
+}
+</style>
diff --git a/templates/tdesign/spa/src/home/api/zjmf.js b/templates/tdesign/spa/src/home/api/zjmf.js
new file mode 100644
index 0000000..2f424f9
--- /dev/null
+++ b/templates/tdesign/spa/src/home/api/zjmf.js
@@ -0,0 +1,13 @@
+import { apiGet } from './http'
+
+/**
+ * zjmfmanager_reserve 插件 API(魔方财务代理分销 - 云服务器)
+ * 依赖插件启用:zjmfmanager_reserve
+ * 注意:购买/主机/订单等用户中心能力统一在 account SPA 或插件独立页面,
+ * home 仅展示公开商品列表
+ */
+
+/** 公开商品列表(游客可访问,含最低价) */
+export function getZjmfProducts() {
+ return apiGet('/reserve/api/public_products')
+}
diff --git a/templates/tdesign/spa/src/home/styles/home.scss b/templates/tdesign/spa/src/home/styles/home.scss
index 6d3de3d..6cefde9 100644
--- a/templates/tdesign/spa/src/home/styles/home.scss
+++ b/templates/tdesign/spa/src/home/styles/home.scss
@@ -691,6 +691,19 @@
font-size: 13px;
min-height: 40px;
line-height: 1.6;
+ /* 简介 li 列表:每项独占一行、带圆点 */
+ ul {
+ margin: 4px 0;
+ padding-left: 16px;
+ }
+ li {
+ display: list-item;
+ list-style: disc;
+ margin: 2px 0;
+ }
+ p {
+ margin: 2px 0;
+ }
}
.hd-plan-body {
diff --git a/templates/tdesign/spa/src/home/views/LandingView.vue b/templates/tdesign/spa/src/home/views/LandingView.vue
index 64b4870..cf02fd1 100644
--- a/templates/tdesign/spa/src/home/views/LandingView.vue
+++ b/templates/tdesign/spa/src/home/views/LandingView.vue
@@ -129,6 +129,43 @@
</div>
</section>
+ <!-- 云服务器区(zjmfmanager_reserve 插件) -->
+ <section v-if="boot.hasZjmf" class="plans-section section">
+ <div class="container">
+ <div class="section-header">
+ <h2 class="section-title">云服务器</h2>
+ <p class="section-subtitle">魔方财务代理分销,即买即用,支付完成自动开通</p>
+ <a :href="accountUrl('zjmf-shop')" class="more-news">查看全部云服务器 ›</a>
+ </div>
+ <div v-if="zjmfProducts.length === 0" class="hd-empty">
+ <i class="mdi mdi-cloud-outline"></i>
+ <p>暂无可购买的云服务器,请联系管理员</p>
+ </div>
+ <div v-else class="hd-plans">
+ <div
+ v-for="(p, i) in zjmfProducts.slice(0, 3)"
+ :key="p.id"
+ class="hd-plan-card"
+ :class="{ pop: i === 1 }"
+ >
+ <div class="hd-plan-top">
+ <span v-if="i === 1" class="hd-plan-chip">推荐</span>
+ <h3>{{ p.name }}</h3>
+ <div class="hd-plan-desc" v-if="p.description" v-html="p.description"></div>
+ <div class="hd-plan-desc" v-else>云服务器服务,即买即用</div>
+ </div>
+ <div class="hd-plan-body">
+ <div class="hd-plan-price">
+ <div class="num">{{ zjmfPriceText(p) }}</div>
+ <div class="sub">即买即用</div>
+ </div>
+ <t-button block theme="primary" size="large" @click="goZjmfBuy">立即购买</t-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
<!-- 核心优势 -->
<section class="features section">
<div class="container">
@@ -222,6 +259,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import authState from '@/home/store/auth'
import { getSiteNews } from '@/home/api/site'
import { getDockerPlans } from '@/home/api/docker'
+import { getZjmfProducts } from '@/home/api/zjmf'
import { formatDate } from '@/home/utils/format'
import { accountUrl } from '@/home/utils/account'
import bg1 from '@/shared/assets/bg1.jpg'
@@ -260,6 +298,22 @@ function dockerSpecText(base, key) {
return '—'
}
+// 云服务器(zjmfmanager_reserve 插件)
+const zjmfProducts = ref([])
+/** 最低价展示:有价显示 ¥x.xx 起,无价显示 咨询价格 */
+function zjmfPriceText(p) {
+ const cents = Number(p.min_price_cents || 0)
+ return cents > 0 ? '¥' + (cents / 100).toFixed(2) + ' 起' : '咨询价格'
+}
+/** 立即购买:与主机/Docker 一致,跳转 account SPA 商城 */
+function goZjmfBuy() {
+ if (!authState.loggedIn) {
+ window.location.href = accountUrl()
+ return
+ }
+ window.location.href = accountUrl('zjmf-shop')
+}
+
// hero 按钮路由:优先官网产品页,无官网插件则回退套餐/登录
const productsUrl = computed(() =>
boot.hasSite ? '/site/products' : (boot.hasShop ? '/shop' : '/')
@@ -365,6 +419,12 @@ onMounted(async () => {
dockerPlans.value = res.data.plans
}
}
+ if (boot.hasZjmf) {
+ const res = await getZjmfProducts()
+ if (res.ok && res.data?.list) {
+ zjmfProducts.value = res.data.list
+ }
+ }
})
onUnmounted(stopAutoPlay)
</script>