会员服务器:上传肥牛应用源码 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
+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>