会员服务器:上传肥牛应用源码 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:
Executable
+156
@@ -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>
|
||||
Executable
+92
@@ -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 校验登录态();
|
||||
})();
|
||||
Executable
+114
@@ -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>
|
||||
Executable
+1
File diff suppressed because one or more lines are too long
Executable
+291
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
Executable
+77
@@ -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; }
|
||||
Executable
+146
@@ -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>
|
||||
Executable
+115
@@ -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>
|
||||
Reference in New Issue
Block a user