Files
dqb-dev 65755bf520 会员服务器:上传肥牛应用源码 v1.5.1(fnnas.huiyuan 完整 fpk 工程结构)
含 app/server/server.cjs 服务端、app/www 网页(admin/auth/项目云/库备份/settings)、
app/ui 图标、cmd 安装脚本、config、wizard、manifest、图标与对接文档。
按仓库规则排除构建产物 *.fpk 与 .mimosa 缓存。
2026-10-02 00:03:20 +08:00

292 lines
14 KiB
HTML
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>