会员服务器:上传肥牛应用源码 v1.5.1(fnnas.huiyuan 完整 fpk 工程结构)

含 app/server/server.cjs 服务端、app/www 网页(admin/auth/项目云/库备份/settings)、
app/ui 图标、cmd 安装脚本、config、wizard、manifest、图标与对接文档。
按仓库规则排除构建产物 *.fpk 与 .mimosa 缓存。
This commit is contained in:
dqb-dev
2026-10-02 00:03:20 +08:00
parent d9be73be89
commit 65755bf520
30 changed files with 2718 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>会员管理</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="wrap">
<header>
<h2>会员管理</h2>
<span class="badge" id="h-ver"></span>
<nav class="nav">
<a class="cur" href="/admin.html">会员管理</a>
<a href="/xiangmu-cloud.html">项目云管理</a>
<a href="/lib-cloud.html">库备份管理</a>
<a href="/settings.html">设置</a>
</nav>
</header>
<div class="card card--table">
<div class="bar">
手机号: <input id="q手机号" style="width:130px">
<button onclick="查询()">查询</button>
<button class="primary" onclick="新增()">新增会员</button>
<button onclick="加载()">刷新列表</button>
<button onclick="保存()">保存修改</button>
<span id="tip"></span>
</div>
<table id="tbl">
<thead>
<tr><th style="width:40px"></th><th>手机号</th><th title="密码加密存储不可查看;输入新值即重置该会员密码,留空保持不变">密码</th><th>会员类型</th><th>有效期</th><th>注册时间</th><th>备注</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<dialog id="dlg新增">
<h3>新增会员</h3>
<div><label>手机号:</label><input id="n手机号"></div>
<div><label>密码:</label><input id="n密码"></div>
<div><label>会员类型:</label>
<select id="n类型" onchange="n类型改()">
<option>试用会员</option><option>月会员</option><option>季会员</option><option>年会员</option><option>永久会员</option>
</select>
</div>
<div><label>有效期:</label><input id="n有效期" placeholder="2027-05-13"></div>
<div class="dlg-actions">
<button onclick="dlg新增.close()">取消</button>
<button class="primary" onclick="确定新增()">确定</button>
</div>
</dialog>
<script src="/auth.js"></script>
<script>
var 数据 = [];
function 提示(s, ok) {
var t = document.getElementById('tip');
t.textContent = s; t.style.color = ok ? '#2a7' : '#c33';
setTimeout(function(){ t.textContent=''; }, 3000);
}
// 会员类型 → 有效期天数(永久会员=永久);改类型时自动算有效期
var 类型天数 = { '试用会员': 30, '月会员': 1, '季会员': 3, '年会员': 12, '永久会员': 0 };
function 今天加月(n) {
var d = new Date(); d.setMonth(d.getMonth() + n);
return d.getFullYear() + '-' + ('0'+(d.getMonth()+1)).slice(-2) + '-' + ('0'+d.getDate()).slice(-2);
}
function 改类型(i, v) {
if (!数据[i]) return;
数据[i]['会员类型'] = v;
var n = 类型天数[v];
if (n === undefined) return; // 自定义类型不动有效期
数据[i]['有效期'] = n === 0 ? '永久' : 今天加月(n);
渲染(); // 重渲染让有效期输入框同步显示新值
}
function 加载() {
fetch('/api/list').then(r => r.json()).then(j => {
数据 = j['会员'] || [];
渲染();
});
}
function 渲染() {
var q = document.getElementById('q手机号').value.trim();
var tb = document.getElementById('tbl').getElementsByTagName('tbody')[0];
tb.innerHTML = '';
数据.forEach(function (m, i) {
if (q && (m['手机号']||'').indexOf(q) < 0) return;
var tr = tb.insertRow();
tr.insertCell().innerHTML = '<input type="checkbox" data-i="'+i+'" class="sel">';
tr.insertCell().innerHTML = '<input value="'+(m['手机号']||'')+'" onchange="改('+i+',\'手机号\',this.value)" style="width:120px">';
tr.insertCell().innerHTML = '<input type="password" value="" placeholder="留空不变" title="密码加密存储不可查看;输入新值即重置,留空保持不变" onchange="改('+i+',\'密码\',this.value)" style="width:100px">';
tr.insertCell().innerHTML = '<select onchange="改类型('+i+',this.value)" style="width:90px">'
+ ['试用会员','月会员','季会员','年会员','永久会员'].map(function(t){ return '<option'+((m['会员类型']||'')===t?' selected':'')+'>'+t+'</option>'; }).join('')
+ '</select>';
tr.insertCell().innerHTML = '<input value="'+(m['有效期']||'')+'" onchange="改('+i+',\'有效期\',this.value)" style="width:100px">';
tr.insertCell().textContent = m['注册时间'] || '';
tr.insertCell().innerHTML = '<input value="'+(m['备注']||'')+'" onchange="改('+i+',\'备注\',this.value)" style="width:120px">';
var op = tr.insertCell(); op.className = 'op';
var del = document.createElement('button');
del.textContent = '删除';
del.className = 'del';
del.onclick = function () {
if (!confirm('确定删除会员 ' + m['手机号'] + ' ?')) return;
数据.splice(i, 1); 渲染(); 保存();
};
op.appendChild(del);
});
}
function 改(i, k, v) { if (数据[i]) { 数据[i][k] = v; } }
// 新增弹窗:选类型 → 自动填有效期(手改过则以手改为准,切类型才覆盖)
function n类型改() {
var v = document.getElementById('n类型').value;
var n = 类型天数[v];
if (n === undefined) return;
document.getElementById('n有效期').value = n === 0 ? '永久' : 今天加月(n);
}
function 保存() {
fetch('/api/save', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ '会员': 数据 }) })
.then(r => r.json()).then(j => 提示(j.ok ? '已保存' : '保存失败:'+(j.msg||''), j.ok))
.catch(function (e) { 提示('请求失败: ' + e, false); });
}
function 查询() { 渲染(); }
function 新增() { document.getElementById('dlg新增').showModal(); }
function 确定新增() {
var 手机号 = document.getElementById('n手机号').value.trim();
var 密码 = document.getElementById('n密码').value.trim();
if (!手机号 || !密码) { alert('手机号和密码不能为空'); return; }
var 类型 = document.getElementById('n类型').value;
var 有效期 = document.getElementById('n有效期').value.trim();
fetch('/api/admin/addmember', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ 手机号: 手机号, 密码: 密码, 会员类型: 类型, 有效期: 有效期 || n类型初值(类型) }) })
.then(r => r.json())
.then(function (j) {
if (!j.ok) { alert(j.msg || '创建失败'); return; }
document.getElementById('dlg新增').close();
提示('已创建会员 ' + 手机号, true);
加载();
})
.catch(function (e) { alert('请求失败: ' + e); });
}
function n类型初值(类型) {
var n = 类型天数[类型];
return n === undefined || n === 0 ? '永久' : 今天加月(n);
}
fetch('/api/version').then(r => r.json()).then(function (j) {
var b = document.getElementById('h-ver');
if (b && j.version) b.textContent = 'v' + j.version;
}).catch(function () {});
加载();
</script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
// 会员服务器 · 管理登录门(四页共用)
// · 页面引入本文件后:无有效登录态时全屏遮罩要求输入管理密码,登录成功写入 localStorage 并刷新
// · 同时包装 window.fetch:对同源 /api/ 请求自动附加 X-Admin-Token 头(页面业务代码无需改动)
// · 管理密码未设置时提示先到安装向导/应用设置中设置
(function () {
var TOKEN_KEY = 'hy_admin_token';
function 取token() { try { return localStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; } }
function 存token(t) { try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { } }
// 原生 fetch 引用(遮罩内登录请求用原生,避免递归)
var 原fetch = window.fetch.bind(window);
// 包装 fetch:同源 /api/ 请求自动带 X-Admin-Token
window.fetch = function (input, init) {
try {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.indexOf('/api/') === 0) {
init = init || {};
var h = {};
if (init.headers && typeof init.headers === 'object' && !(init.headers instanceof Headers)) h = init.headers;
h['X-Admin-Token'] = 取token();
init.headers = h;
}
} catch (e) { }
return 原fetch(input, init);
};
window.HY = {
token: 取token,
logout: function () { try { localStorage.removeItem(TOKEN_KEY); } catch (e) { } location.reload(); }
};
// ---- 登录遮罩 ----
var 样式 = document.createElement('style');
样式.textContent =
'#hy-login{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:#f5f6f8}' +
'#hy-login .box{background:#fff;border:1px solid #e3e6eb;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.12);padding:32px 34px;width:340px;max-width:92vw}' +
'#hy-login h3{margin:0 0 6px;font-size:18px;font-family:"Microsoft YaHei UI","Microsoft YaHei",sans-serif}' +
'#hy-login .sub{color:#7c8590;font-size:12px;margin:0 0 16px;font-family:"Microsoft YaHei UI","Microsoft YaHei",sans-serif}' +
'#hy-login input{width:100%;padding:9px 11px;border:1px solid #cfd5dd;border-radius:7px;font-size:14px;box-sizing:border-box}' +
'#hy-login input:focus{outline:none;border-color:#2563eb}' +
'#hy-login button{width:100%;margin-top:12px;padding:9px;border:none;border-radius:7px;background:#2563eb;color:#fff;font-size:14px;cursor:pointer}' +
'#hy-login button:hover{background:#1d54d7}' +
'#hy-login .err{color:#c53030;font-size:12px;min-height:16px;margin-top:10px;font-family:"Microsoft YaHei UI","Microsoft YaHei",sans-serif}';
document.head.appendChild(样式);
function 显示登录(提示语) {
var 旧 = document.getElementById('hy-login');
if (旧) 旧.remove();
var ov = document.createElement('div');
ov.id = 'hy-login';
ov.innerHTML = '<div class="box"><h3>会员服务器</h3><p class="sub">请输入管理密码登录'
+ (提示语 ? '(' + 提示语 + ')' : '') + '</p>'
+ '<input type="password" id="hy-pwd" placeholder="管理密码" autocomplete="current-password">'
+ '<button id="hy-btn">登 录</button><div class="err" id="hy-err"></div></div>';
document.body.appendChild(ov);
var pwd = ov.querySelector('#hy-pwd'), btn = ov.querySelector('#hy-btn'), err = ov.querySelector('#hy-err');
function 登录() {
var v = pwd.value;
if (!v) { err.textContent = '请输入管理密码'; return; }
btn.disabled = true; btn.textContent = '登录中…'; err.textContent = '';
原fetch('/api/admin/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 密码: v })
}).then(function (r) { return r.json(); }).then(function (j) {
if (j.ok) { 存token(j.token); location.reload(); return; }
err.textContent = j.msg || '登录失败';
btn.disabled = false; btn.textContent = '登 录'; pwd.value = ''; pwd.focus();
}).catch(function (e) {
err.textContent = '请求失败: ' + e;
btn.disabled = false; btn.textContent = '登 录';
});
}
btn.onclick = 登录;
pwd.addEventListener('keydown', function (e) { if (e.key === 'Enter') 登录(); });
setTimeout(function () { pwd.focus(); }, 50);
}
function 校验登录态() {
原fetch('/api/admin/check', { headers: { 'X-Admin-Token': 取token() } })
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.ok) return; // 已登录,页面正常
显示登录(j.adminConfigured ? '' : '首次使用请先在安装向导/应用设置中设置管理密码');
})
.catch(function () { 显示登录('无法连接服务器'); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', 校验登录态);
else 校验登录态();
})();
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>库备份管理</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="wrap">
<header>
<h2>库备份管理</h2>
<span class="badge" id="h-ver"></span>
<nav class="nav">
<a href="/admin.html">会员管理</a>
<a href="/xiangmu-cloud.html">项目云管理</a>
<a class="cur" href="/lib-cloud.html">库备份管理</a>
<a href="/settings.html">设置</a>
</nav>
</header>
<p class="sub">方案库/用户库/柜体库/铜排铝排库/企业信息等库备份(按手机号),可下载或删除。</p>
<div class="card card--table">
<div class="bar">
手机号:
<select id="c手机号sel" onchange="同步手机号()" style="width:170px"><option value="">-- 选择会员 --</option></select>
或输入: <input id="c手机号" style="width:130px" placeholder="手动输入">
<button class="primary" onclick="云查询()">查看云上库备份</button>
<span id="tip"></span>
</div>
<table id="云tbl">
<thead>
<tr><th>库名</th><th>原始路径</th><th>备份时间</th><th>大小</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script src="/auth.js"></script>
<script>
function 提示(s, ok) {
var t = document.getElementById('tip');
t.textContent = s; t.style.color = ok ? '#2a7' : '#c33';
}
function 加载会员下拉() {
fetch('/api/list').then(r => r.json()).then(function (j) {
var sel = document.getElementById('c手机号sel');
(j['会员'] || []).forEach(function (m) {
var p = m['手机号'] || '';
if (!p) return;
var op = document.createElement('option');
op.value = p;
op.textContent = p + (m['会员类型'] ? ' (' + m['会员类型'] + ')' : '');
sel.appendChild(op);
});
}).catch(function () {});
}
function 同步手机号() {
var v = document.getElementById('c手机号sel').value;
if (v) document.getElementById('c手机号').value = v;
}
function 格式大小(b) {
if (b >= 1048576) return (b / 1048576).toFixed(1) + 'M';
if (b >= 1024) return (b / 1024).toFixed(1) + 'K';
return b + 'B';
}
function 云查询() {
var p = document.getElementById('c手机号').value.trim();
if (!p) { 提示('请选择或输入手机号', false); return; }
fetch('/api/proj/list?手机号=' + encodeURIComponent(p)).then(r => r.json()).then(function (j) {
var tb = document.getElementById('云tbl').getElementsByTagName('tbody')[0];
tb.innerHTML = '';
if (!j.ok) { 提示(j.msg || '查询失败', false); return; }
// 只显示 LIB_ 开头的库条目(项目在 xiangmu-cloud.html)
var list = (j['项目'] || []).filter(function (pr) { return (pr.编号 || '').indexOf('LIB_') === 0; });
if (!list.length) { 提示('该手机号云上暂无库备份', true); return; }
提示('共 ' + list.length + ' 个库备份', true);
list.forEach(function (pr) {
var 名称 = pr.名称 || '', 显示名 = 名称, 路径 = '';
var 竖 = 名称.indexOf('|');
if (竖 >= 0) { 显示名 = 名称.slice(0, 竖); 路径 = 名称.slice(竖 + 1); }
var tr = tb.insertRow();
tr.insertCell().textContent = 显示名;
tr.insertCell().textContent = 路径;
tr.insertCell().textContent = pr.时间 || '';
tr.insertCell().textContent = 格式大小(pr.大小 || 0);
var op = tr.insertCell(); op.className = 'op';
var del = document.createElement('button');
del.textContent = '删除'; del.className = 'del';
del.onclick = function () {
if (!confirm('确定删除云端库备份 ' + 显示名 + ' ?')) return;
fetch('/api/proj/delete', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ 手机号: p, 编号: pr.编号 }) })
.then(r => r.json()).then(function (d) { 提示(d.ok ? '已删除' : '删除失败:'+(d.msg||''), d.ok); 云查询(); });
};
op.appendChild(del);
var dl = document.createElement('button');
dl.textContent = '下载';
dl.onclick = function () {
location.href = '/api/proj/download?手机号=' + encodeURIComponent(p) + '&编号=' + encodeURIComponent(pr.编号);
};
op.appendChild(dl);
});
}).catch(function (e) { 提示('请求失败: ' + e, false); });
}
fetch('/api/version').then(r => r.json()).then(function (j) {
var b = document.getElementById('h-ver');
if (b && j.version) b.textContent = 'v' + j.version;
}).catch(function () {});
加载会员下拉();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+291
View File
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>会员服务器 · 设置</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="wrap">
<header>
<h2>设置</h2>
<span class="badge" id="h-ver"></span>
<nav class="nav">
<a href="/admin.html">会员管理</a>
<a href="/xiangmu-cloud.html">项目云管理</a>
<a href="/lib-cloud.html">库备份管理</a>
<a class="cur" href="/settings.html">设置</a>
</nav>
</header>
<div class="card">
<h3>服务信息</h3>
<p class="sub">成套报价软件客户端连接地址:飞牛主机 IP + 下方端口(如 192.168.x.x:端口)</p>
<div class="rows">
<div class="k">服务端口</div><div class="v"><span id="s-port"></span></div>
<div class="k">会员数量</div><div class="v"><span id="s-count"></span> 名</div>
<div class="k">数据库文件</div><div class="v"><code id="s-dbfile"></code></div>
<div class="k">项目云目录</div><div class="v"><code id="s-projroot"></code></div>
</div>
<p class="hint">端口如需修改:飞牛桌面 → 应用中心 → 会员服务器 → 应用设置(留空表示不修改,保存后应用自动重启)。</p>
</div>
<div class="card">
<h3>数据目录</h3>
<p class="sub">存放数据库(huiyuan.json)与项目云数据(会员的项目/库备份 zip),保存后自动把现有数据迁移过去。</p>
<div class="opts" id="opts"></div>
<div class="actions">
<button id="btn-auth">授权新目录</button>
<button id="btn-save" class="primary">保存并迁移</button>
<span id="tip"></span>
</div>
<p class="hint">「授权新目录」把所选目录授权给本应用(需管理员,在飞牛桌面内操作);也可在 应用设置 → 目录授权 中手动添加后刷新本页选择。</p>
<p class="warn" id="fb-warn"></p>
<p class="warn" id="auth-warn"></p>
</div>
<div class="card">
<h3>加密通道</h3>
<p class="sub">全站强制 HTTPS。客户端请使用下方加密地址连接;凭据类通讯在 TLS 之上还有信封加密(RSA+AES+HMAC+防重放)。</p>
<div class="rows">
<div class="k">加密地址</div><div class="v"><code id="s-encurl"></code></div>
<div class="k">TLS 证书指纹</div><div class="v"><code id="s-tlsfp">-</code></div>
<div class="k">RSA 公钥指纹</div><div class="v"><code id="s-keyfp">-</code></div>
</div>
<div class="actions">
<button id="btn-cert">下载证书(安装到"受信任的根证书颁发机构"后不再有警告)</button>
</div>
</div>
<div class="card">
<h3>SSL 证书</h3>
<p class="sub">导入正规 SSL 证书(如为 ss61.odn.cc 签发的证书,证书包解压出的 .pem 文件)后,浏览器访问不再有证书警告。导入立即生效,无需重启;升级重装会自动保留。</p>
<div class="rows">
<div class="k">当前证书</div><div class="v" id="s-certinfo">加载中…</div>
</div>
<div class="rows" style="margin-top:12px">
<div class="k">证书文件</div><div class="v"><input type="file" id="f-cert" accept=".pem,.crt,.cer,.crt.pem" style="width:320px">(fullchain 全链文件,含证书链)</div>
<div class="k">私钥文件</div><div class="v"><input type="file" id="f-key" accept=".pem,.key" style="width:320px">(与证书配对的私钥)</div>
</div>
<div class="actions">
<button id="btn-import" class="primary">导入并生效</button>
<button id="btn-reset-tls">恢复自签证书</button>
<span id="tls-tip" style="font-size:13px;color:#1a8a55"></span>
</div>
<p class="hint">申请 ss61.odn.cc 的证书:在 odn 控制台把该域名的 80 端口也转发到本机一个端口,然后在 NAS 上执行<br>
<code>acme.sh --issue -d ss61.odn.cc --standalone --httpport 转发的端口 --server letsencrypt</code><br>
签发成功后,证书在 <code>~/.acme.sh/ss61.odn.cc_ecc/</code>(fullchain.cer 与 ss61.odn.cc.key 即上面要导入的两个文件)。</p>
</div>
<div class="card">
<h3>安全</h3>
<p class="sub">管理密码用于登录管理页面并保护管理接口;修改后其他已登录的浏览器需重新登录(token 24 小时后也需重新登录)。</p>
<div class="rows">
<div class="k">开放注册</div>
<div class="v"><label style="cursor:pointer"><input type="checkbox" id="reg-open" style="width:auto;vertical-align:-2px"> 允许客户端自助注册会员(关闭后只能由管理员在会员管理中录入)</label></div>
</div>
<div class="rows" style="margin-top:12px">
<div class="k">新管理密码</div><div class="v"><input type="password" id="npwd" placeholder="至少 6 位" style="width:220px" autocomplete="new-password"></div>
<div class="k">确认新密码</div><div class="v"><input type="password" id="npwd2" style="width:220px" autocomplete="new-password"></div>
</div>
<div class="actions">
<button id="btn-chgpwd" class="primary">修改管理密码</button>
<button id="btn-logout">退出登录</button>
<span id="sec-tip" style="font-size:13px;color:#1a8a55"></span>
</div>
</div>
</div>
<script src="/auth.js"></script>
<script type="module">
let sdk = null;
import('./sdk/web-app.js').then(m => { try { sdk = new m.TrimApp(); } catch (e) { console.warn('SDK init', e); } })
.catch(e => console.warn('SDK 未加载(独立环境不影响其他功能)', e));
const $ = s => document.querySelector(s);
let S = null; // /api/settings 缓存
let tipTimer;
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function 提示(s, ok) {
const t = $('#tip');
t.textContent = s; t.className = ok === false ? 'err' : '';
clearTimeout(tipTimer); tipTimer = setTimeout(() => { t.textContent = ''; t.className = ''; }, 5000);
}
async function 加载设置() {
const r = await fetch('/api/settings');
S = await r.json();
if (!S.ok) return; // 未登录时 auth.js 会接管,这里直接退出
$('#h-ver').textContent = 'v' + S.version;
$('#s-port').textContent = S.port;
$('#s-count').textContent = S.count;
$('#s-dbfile').textContent = S.dbfile;
$('#s-projroot').textContent = S.projroot;
document.getElementById('reg-open').checked = !!S.regOpen;
$('#s-encurl').textContent = 'https://' + location.hostname + ':' + S.port;
$('#s-tlsfp').textContent = S.tlsfp || '-';
$('#s-keyfp').textContent = S.keyfp || '-';
渲染目录选项();
// 配置了自定义目录但当前不可写(已回退默认)的提示
const fb = $('#fb-warn');
if (S.configuredDir && S.configuredDir !== S.dbdir) {
fb.style.display = '';
fb.textContent = '⚠ 配置的数据目录 ' + S.configuredDir + ' 当前不可写,已临时回退到默认目录;恢复该目录权限并重启应用后自动切回。';
} else fb.style.display = 'none';
}
function 渲染目录选项() {
const box = $('#opts');
const opts = [{ dir: S.defaultdir, name: '应用默认目录', def: true }];
(S.authorized || []).forEach(p => { if (p !== S.defaultdir) opts.push({ dir: p, name: '已授权目录' }); });
if (!S.isDefaultDir && !(S.authorized || []).includes(S.dbdir))
opts.push({ dir: S.dbdir, name: '当前使用目录' });
box.innerHTML = opts.map(o => {
const cur = o.dir === S.dbdir;
return `<label class="opt${cur ? ' sel' : ''}"><input type="radio" name="dbdir" value="${esc(o.dir)}" ${cur ? 'checked' : ''}>
<div class="body">
<div class="title"><span class="name">${o.name}</span>
${o.def ? '<span class="tag def">默认</span>' : ''}${cur ? '<span class="tag cur">当前</span>' : ''}</div>
<code>${esc(o.dir)}</code>
</div></label>`;
}).join('') +
`<label class="opt" id="opt-custom"><input type="radio" name="dbdir" id="r-custom">
<div class="body"><div class="title"><span class="name">自定义路径</span></div>
<input type="text" id="custom-input" placeholder="/vol1/1000/xxx/会员数据" spellcheck="false"></div></label>`;
}
// 选项块选中态高亮(事件委托,只绑定一次)
function 同步高亮() {
document.querySelectorAll('#opts .opt').forEach(l =>
l.classList.toggle('sel', l.querySelector('input').checked));
}
$('#opts').addEventListener('change', 同步高亮);
// 在自定义输入框聚焦/输入时自动选中"自定义路径"单选
['focus', 'input'].forEach(ev =>
document.addEventListener(ev, e => {
if (e.target && e.target.id === 'custom-input' && !$('#r-custom').checked) {
$('#r-custom').checked = true; 同步高亮();
}
}, true));
function 选中目录() {
const c = document.querySelector('input[name=dbdir]:checked');
if (!c) return '';
if (c.id === 'r-custom') return $('#custom-input').value.trim();
return c.value;
}
$('#btn-auth').onclick = async () => {
if (!sdk) return 提示('JS SDK 未加载,请从飞牛桌面打开本应用', false);
if (sdk.isStandaloneWeb) return 提示('当前是独立浏览器窗口,请在飞牛桌面内打开本应用后授权,或到 应用设置→目录授权 手动添加', false);
try {
const r = await sdk.pickSharedFile({ title: '选择数据目录', okText: '确认授权' });
if (r && r.code === 0) { 提示('授权成功,列表已刷新'); 加载设置(); }
else if (r && r.code === 1003201) 提示('仅管理员可授权,请用管理员账号操作', false);
else 提示('授权未完成:' + ((r && (r.msg || JSON.stringify(r))) || '无返回'), false);
} catch (e) { 提示('授权调用异常:' + e, false); }
};
$('#btn-save').onclick = async () => {
const dir = 选中目录();
if (!dir) return 提示('请先选择或输入数据目录', false);
if (!/^\/.+/.test(dir)) return 提示('目录必须是以 / 开头的完整路径', false);
if (dir === S.dbdir) return 提示('目录未变化,无需迁移');
if (!confirm('将把数据库和项目云数据迁移到:\n' + dir + '\n\n确定继续?')) return;
提示('正在迁移…');
try {
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dbdir: dir }) });
const j = await r.json();
if (j.ok) { 提示('已迁移到 ' + j.dbfile + (j.msg ? '(' + j.msg + ')' : '')); 加载设置(); }
else 提示('迁移失败:' + (j.msg || '未知错误'), false);
} catch (e) { 提示('请求异常:' + e, false); }
};
// ---- 安全卡片 ----
let secTimer;
function 安全提示(s, ok) {
const t = $('#sec-tip');
t.textContent = s; t.style.color = ok === false ? '#c53030' : '#1a8a55';
clearTimeout(secTimer); secTimer = setTimeout(() => t.textContent = '', 5000);
}
document.getElementById('reg-open').addEventListener('change', async e => {
try {
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ regOpen: e.target.checked }) });
const j = await r.json();
安全提示(j.ok ? (j.regOpen ? '已开放注册' : '已关闭注册') : (j.msg || '操作失败'), j.ok);
} catch (e2) { 安全提示('请求异常:' + e2, false); }
});
document.getElementById('btn-chgpwd').onclick = async () => {
const p1 = document.getElementById('npwd').value, p2 = document.getElementById('npwd2').value;
if (p1.length < 6) return 安全提示('新管理密码至少 6 位', false);
if (p1 !== p2) return 安全提示('两次输入的密码不一致', false);
try {
const r = await fetch('/api/admin/changepwd', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 新密码: p1 }) });
const j = await r.json();
if (j.ok) {
安全提示('管理密码已修改');
document.getElementById('npwd').value = '';
document.getElementById('npwd2').value = '';
} else 安全提示(j.msg || '修改失败', false);
} catch (e2) { 安全提示('请求异常:' + e2, false); }
};
document.getElementById('btn-logout').onclick = () => window.HY.logout();
document.getElementById('btn-cert').onclick = () => { location.href = '/api/cert'; };
// ---- SSL 证书导入 ----
let tlsTimer;
function tls提示(s, ok) {
const t = $('#tls-tip');
t.textContent = s; t.style.color = ok === false ? '#c53030' : '#1a8a55';
clearTimeout(tlsTimer); tlsTimer = setTimeout(() => t.textContent = '', 6000);
}
async function 加载证书信息() {
try {
const r = await fetch('/api/admin/tls/info');
const j = await r.json();
if (!j.ok) { $('#s-certinfo').textContent = j.msg || '读取失败'; return; }
const t = j.tls;
$('#s-certinfo').innerHTML =
(t.source === 'custom' ? '<span class="tag cur">已导入证书</span>' : '<span class="tag def">自签证书</span>')
+ ' ' + esc(t.subject) + (t.san ? ',域名: ' + esc(t.san) : '')
+ '<br>签发者: ' + esc(t.issuer) + ',有效期至: ' + esc(t.notAfter);
} catch (e) { $('#s-certinfo').textContent = '读取失败'; }
}
async function 读文件文本(input) {
const f = input.files && input.files[0];
if (!f) return null;
return await f.text();
}
document.getElementById('btn-import').onclick = async () => {
const cert = await 读文件文本(document.getElementById('f-cert'));
const key = await 读文件文本(document.getElementById('f-key'));
if (!cert || !key) return tls提示('请先选择证书文件和私钥文件', false);
if (!confirm('导入后立即生效(新连接使用新证书)。确定导入?')) return;
tls提示('正在导入…');
try {
const r = await fetch('/api/admin/tls/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cert, key }) });
const j = await r.json();
tls提示(j.ok ? (j.msg || '导入成功') : (j.msg || '导入失败'), j.ok);
if (j.ok) { 加载证书信息(); 加载设置(); }
} catch (e) { tls提示('请求异常:' + e, false); }
};
document.getElementById('btn-reset-tls').onclick = async () => {
if (!confirm('恢复为自动生成的自签证书?已导入的证书将被覆盖。')) return;
try {
const r = await fetch('/api/admin/tls/reset', { method: 'POST' });
const j = await r.json();
tls提示(j.ok ? '已恢复自签证书' : (j.msg || '重置失败'), j.ok);
if (j.ok) { 加载证书信息(); 加载设置(); }
} catch (e) { tls提示('请求异常:' + e, false); }
};
加载设置();
加载证书信息();
</script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
/* 会员服务器 · 统一样式(四页共用:会员管理/项目云管理/库备份管理/设置) */
* { box-sizing: border-box; }
body { font-family: "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; font-size: 14px; margin: 0; padding: 24px; background: #f5f6f8; color: #1f2329; }
.wrap { max-width: 960px; margin: 0 auto; }
/* ---- 顶部:标题 + 徽章 + 页面导航 ---- */
header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
header h2 { margin: 0; font-size: 20px; }
.badge { background: #e8efff; color: #2563eb; border-radius: 10px; padding: 2px 10px; font-size: 12px; white-space: nowrap; }
.nav { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
.nav a { padding: 6px 14px; border-radius: 8px; color: #4b5563; text-decoration: none; font-size: 13px; white-space: nowrap; }
.nav a:hover { background: #eceff3; color: #1f2329; }
.nav a.cur { background: #2563eb; color: #fff; }
.sub { color: #7c8590; font-size: 12px; margin: 0 0 14px; line-height: 1.7; }
/* ---- 卡片 ---- */
.card { background: #fff; border: 1px solid #e3e6eb; border-radius: 10px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
.card--table { padding: 0; overflow: hidden; }
.card h3 { margin: 0 0 4px; font-size: 15px; }
.card .sub { margin: 0 0 12px; }
/* ---- 工具条 ---- */
.bar { padding: 14px 16px; border-bottom: 1px solid #eef0f3; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* ---- 表格 ---- */
table { border-collapse: collapse; width: 100%; background: #fff; }
th, td { border: 1px solid #e8ebef; padding: 7px 10px; text-align: left; }
th { background: #f7f8fa; font-weight: 600; white-space: nowrap; }
tbody tr:hover td { background: #f7faff; }
.card--table th, .card--table td { border-left: none; border-right: none; }
.card--table th { border-top: none; }
.card--table tr:last-child td { border-bottom: none; }
.op button { margin-right: 4px; padding: 3px 10px; font-size: 12px; }
.del { color: #c53030; }
/* ---- 按钮与输入 ---- */
button { padding: 7px 16px; border-radius: 7px; border: 1px solid #cfd5dd; background: #fff; color: #1f2329; cursor: pointer; font-size: 13px; }
button:hover { border-color: #9db8f0; background: #fafbff; }
button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
button.primary:hover { background: #1d54d7; }
input, select { padding: 6px 9px; border: 1px solid #cfd5dd; border-radius: 6px; font-size: 13px; background: #fff; color: #1f2329; }
input:focus, select:focus { outline: none; border-color: #2563eb; }
#tip { font-size: 13px; color: #1a8a55; }
#tip.err { color: #c53030; }
/* ---- 信息展示 ---- */
.rows { display: grid; grid-template-columns: 110px 1fr; row-gap: 9px; align-items: baseline; }
.rows .k { color: #7c8590; }
.rows .v { word-break: break-all; }
code { background: #f2f4f7; border: 1px solid #e8ebef; border-radius: 5px; padding: 2px 7px; font-size: 13px; font-family: Consolas, Menlo, monospace; }
.tag { font-size: 12px; border-radius: 9px; padding: 1px 9px; white-space: nowrap; }
.tag.cur { background: #e6f6ee; color: #1a8a55; }
.tag.def { background: #e8efff; color: #2563eb; }
.hint { color: #98a0aa; font-size: 12px; margin: 16px 0 0; line-height: 1.8; }
.warn { display: none; background: #fff7e8; border: 1px solid #f5deb3; color: #9a6700; border-radius: 8px; padding: 8px 12px; font-size: 12px; margin: 10px 0 0; line-height: 1.7; word-break: break-all; }
/* ---- 设置页:数据目录选项块 ---- */
.opts { display: flex; flex-direction: column; gap: 8px; margin: 4px 0; }
.opt { display: flex; align-items: center; gap: 10px; border: 1px solid #dfe3e9; border-radius: 8px; padding: 10px 12px; cursor: pointer; transition: border-color .15s, background .15s; }
.opt:hover { border-color: #9db8f0; background: #fafbff; }
.opt.sel { border-color: #2563eb; background: #f3f7ff; }
.opt input[type=radio] { width: 15px; height: 15px; margin: 0; flex: none; accent-color: #2563eb; }
.opt .body { flex: 1; min-width: 0; }
.opt .title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.opt .title .name { font-weight: 600; }
.opt code { display: inline-block; margin-top: 3px; overflow-wrap: anywhere; }
.opt input[type=text] { width: 100%; padding: 6px 9px; margin-top: 6px; }
.actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
/* ---- 弹窗(会员管理·新增会员) ---- */
dialog { border: none; border-radius: 12px; padding: 20px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.18); min-width: 340px; }
dialog::backdrop { background: rgba(15,23,42,.4); }
dialog h3 { margin: 0 0 14px; font-size: 16px; }
dialog > div { margin-bottom: 10px; }
dialog label { display: inline-block; width: 80px; color: #5c6672; }
dialog input, dialog select { width: 220px; }
.dlg-actions { text-align: right; margin-top: 14px; margin-bottom: 0; }
+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>项目云管理</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="wrap">
<header>
<h2>项目云管理</h2>
<span class="badge" id="h-ver"></span>
<nav class="nav">
<a href="/admin.html">会员管理</a>
<a class="cur" href="/xiangmu-cloud.html">项目云管理</a>
<a href="/lib-cloud.html">库备份管理</a>
<a href="/settings.html">设置</a>
</nav>
</header>
<p class="sub">按手机号查看名下云上项目(客户端上传的项目 zip),可下载或删除。</p>
<div class="card card--table">
<div class="bar">
手机号:
<select id="c手机号sel" onchange="同步手机号()" style="width:170px"><option value="">-- 选择会员 --</option></select>
或输入: <input id="c手机号" style="width:130px" placeholder="手动输入">
<button class="primary" onclick="云查询()">查看云上项目</button>
<button id="btn删选" onclick="删除选中()" style="background:#c33;color:#fff">删除选中</button>
<span id="tip"></span>
</div>
<table id="云tbl">
<thead>
<tr><th style="width:40px"><input type="checkbox" title="全选/全不选" onchange="全选(this)"></th><th>项目编号</th><th>项目名称</th><th>上传时间</th><th>大小</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script src="/auth.js"></script>
<script>
function 提示(s, ok) {
var t = document.getElementById('tip');
t.textContent = s; t.style.color = ok ? '#2a7' : '#c33';
}
// 下拉选会员:从 /api/list 拉全部手机号填充;选中即同步到输入框(输入框仍可手改)
function 加载会员下拉() {
fetch('/api/list').then(r => r.json()).then(function (j) {
var sel = document.getElementById('c手机号sel');
var cur = sel.value;
sel.innerHTML = '<option value="">-- 选择会员 --</option>';
(j['会员'] || []).forEach(function (m) {
var p = m['手机号'] || '';
if (!p) return;
var op = document.createElement('option');
op.value = p;
op.textContent = p + (m['会员类型'] ? ' (' + m['会员类型'] + ')' : '');
sel.appendChild(op);
});
if (cur) sel.value = cur;
}).catch(function () {});
}
function 同步手机号() {
var v = document.getElementById('c手机号sel').value;
if (v) document.getElementById('c手机号').value = v;
}
function 格式大小(b) {
if (b >= 1048576) return (b / 1048576).toFixed(1) + 'M';
if (b >= 1024) return (b / 1024).toFixed(1) + 'K';
return b + 'B';
}
function 全选(cb) {
[].forEach.call(document.querySelectorAll('#云tbl .云sel'), function (c) { c.checked = cb.checked; });
}
function 删除选中() {
var p = document.getElementById('c手机号').value.trim();
if (!p) { 提示('请先输入手机号并查看项目', false); return; }
var 行 = [].slice.call(document.querySelectorAll('#云tbl tbody tr')).filter(function (r) {
var c = r.querySelector('.云sel');
return c && c.checked;
});
if (!行.length) { 提示('请先勾选要删除的项目', false); return; }
if (!confirm('确定删除选中的 ' + 行.length + ' 个云端项目?')) return;
var 完成 = 0, 失败 = 0, i = 0;
(function 下一条() {
if (i >= 行.length) {
提示('删除完成: 成功 ' + 完成 + ' 个' + (失败 ? ',失败 ' + 失败 + ' 个' : ''), 失败 === 0);
云查询();
return;
}
var tr = 行[i++];
提示('正在删除 ' + i + '/' + 行.length + ' ...', true);
fetch('/api/proj/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 手机号: p, 编号: tr.getAttribute('data-bh') }) })
.then(r => r.json())
.then(function (d) { if (d.ok) 完成++; else 失败++; })
.catch(function () { 失败++; })
.then(下一条);
})();
}
function 云查询() {
var p = document.getElementById('c手机号').value.trim();
if (!p) { 提示('请输入手机号', false); return; }
fetch('/api/proj/list?手机号=' + encodeURIComponent(p)).then(r => r.json()).then(function (j) {
var tb = document.getElementById('云tbl').getElementsByTagName('tbody')[0];
tb.innerHTML = '';
if (!j.ok) { 提示(j.msg || '查询失败', false); return; }
var list = (j['项目'] || []).filter(function (pr) { return (pr.编号 || '').indexOf('LIB_') !== 0; }); // 库备份在 lib-cloud.html,这里只显示项目
if (!list.length) { 提示('该手机号云上暂无项目', true); return; }
提示('共 ' + list.length + ' 个项目', true);
list.forEach(function (pr) {
var tr = tb.insertRow();
tr.setAttribute('data-bh', pr.编号 || '');
tr.insertCell().innerHTML = '<input type="checkbox" class="云sel">';
tr.insertCell().textContent = pr.编号 || '';
tr.insertCell().textContent = pr.名称 || '';
tr.insertCell().textContent = pr.时间 || '';
tr.insertCell().textContent = 格式大小(pr.大小 || 0);
var op = tr.insertCell(); op.className = 'op';
var del = document.createElement('button');
del.textContent = '删除'; del.className = 'del';
del.onclick = function () {
if (!confirm('确定删除云端项目 ' + (pr.名称 || pr.编号) + ' ?')) return;
fetch('/api/proj/delete', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ 手机号: p, 编号: pr.编号 }) })
.then(r => r.json()).then(function (d) { 提示(d.ok ? '已删除' : '删除失败:'+(d.msg||''), d.ok); 云查询(); });
};
op.appendChild(del);
var dl = document.createElement('button');
dl.textContent = '下载';
dl.onclick = function () {
location.href = '/api/proj/download?手机号=' + encodeURIComponent(p) + '&编号=' + encodeURIComponent(pr.编号);
};
op.appendChild(dl);
});
}).catch(function (e) { 提示('请求失败: ' + e, false); });
}
fetch('/api/version').then(r => r.json()).then(function (j) {
var b = document.getElementById('h-ver');
if (b && j.version) b.textContent = 'v' + j.version;
}).catch(function () {});
加载会员下拉();
</script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>项目云管理</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="wrap">
<header>
<h2>项目云管理</h2>
<span class="badge" id="h-ver"></span>
<nav class="nav">
<a href="/admin.html">会员管理</a>
<a class="cur" href="/xiangmu-cloud.html">项目云管理</a>
<a href="/lib-cloud.html">库备份管理</a>
<a href="/settings.html">设置</a>
</nav>
</header>
<p class="sub">按手机号查看名下云上项目(客户端上传的项目 zip),可下载或删除。</p>
<div class="card card--table">
<div class="bar">
手机号:
<select id="c手机号sel" onchange="同步手机号()" style="width:170px"><option value="">-- 选择会员 --</option></select>
或输入: <input id="c手机号" style="width:130px" placeholder="手动输入">
<button class="primary" onclick="云查询()">查看云上项目</button>
<span id="tip"></span>
</div>
<table id="云tbl">
<thead>
<tr><th style="width:40px"></th><th>项目编号</th><th>项目名称</th><th>上传时间</th><th>大小</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script src="/auth.js"></script>
<script>
function 提示(s, ok) {
var t = document.getElementById('tip');
t.textContent = s; t.style.color = ok ? '#2a7' : '#c33';
}
// 下拉选会员:从 /api/list 拉全部手机号填充;选中即同步到输入框(输入框仍可手改)
function 加载会员下拉() {
fetch('/api/list').then(r => r.json()).then(function (j) {
var sel = document.getElementById('c手机号sel');
var cur = sel.value;
sel.innerHTML = '<option value="">-- 选择会员 --</option>';
(j['会员'] || []).forEach(function (m) {
var p = m['手机号'] || '';
if (!p) return;
var op = document.createElement('option');
op.value = p;
op.textContent = p + (m['会员类型'] ? ' (' + m['会员类型'] + ')' : '');
sel.appendChild(op);
});
if (cur) sel.value = cur;
}).catch(function () {});
}
function 同步手机号() {
var v = document.getElementById('c手机号sel').value;
if (v) document.getElementById('c手机号').value = v;
}
function 格式大小(b) {
if (b >= 1048576) return (b / 1048576).toFixed(1) + 'M';
if (b >= 1024) return (b / 1024).toFixed(1) + 'K';
return b + 'B';
}
function 云查询() {
var p = document.getElementById('c手机号').value.trim();
if (!p) { 提示('请输入手机号', false); return; }
fetch('/api/proj/list?手机号=' + encodeURIComponent(p)).then(r => r.json()).then(function (j) {
var tb = document.getElementById('云tbl').getElementsByTagName('tbody')[0];
tb.innerHTML = '';
if (!j.ok) { 提示(j.msg || '查询失败', false); return; }
var list = (j['项目'] || []).filter(function (pr) { return (pr.编号 || '').indexOf('LIB_') !== 0; }); // 库备份在 lib-cloud.html,这里只显示项目
if (!list.length) { 提示('该手机号云上暂无项目', true); return; }
提示('共 ' + list.length + ' 个项目', true);
list.forEach(function (pr) {
var tr = tb.insertRow();
tr.insertCell().innerHTML = '<input type="checkbox" class="云sel">';
tr.insertCell().textContent = pr.编号 || '';
tr.insertCell().textContent = pr.名称 || '';
tr.insertCell().textContent = pr.时间 || '';
tr.insertCell().textContent = 格式大小(pr.大小 || 0);
var op = tr.insertCell(); op.className = 'op';
var del = document.createElement('button');
del.textContent = '删除'; del.className = 'del';
del.onclick = function () {
if (!confirm('确定删除云端项目 ' + (pr.名称 || pr.编号) + ' ?')) return;
fetch('/api/proj/delete', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ 手机号: p, 编号: pr.编号 }) })
.then(r => r.json()).then(function (d) { 提示(d.ok ? '已删除' : '删除失败:'+(d.msg||''), d.ok); 云查询(); });
};
op.appendChild(del);
var dl = document.createElement('button');
dl.textContent = '下载';
dl.onclick = function () {
location.href = '/api/proj/download?手机号=' + encodeURIComponent(p) + '&编号=' + encodeURIComponent(pr.编号);
};
op.appendChild(dl);
});
}).catch(function (e) { 提示('请求失败: ' + e, false); });
}
fetch('/api/version').then(r => r.json()).then(function (j) {
var b = document.getElementById('h-ver');
if (b && j.version) b.textContent = 'v' + j.version;
}).catch(function () {});
加载会员下拉();
</script>
</body>
</html>