会员服务器:上传肥牛应用源码 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
+911
View File
@@ -0,0 +1,911 @@
// 会员系统服务器(零依赖纯 node http)
// · 端口: HY_PORT → MEMBER_PORT → TRIM_SERVICE_PORT → 12701;
// 绑定 MEMBER_HOST 或 0.0.0.0(供局域网内成套报价软件访问)
// · 数据库存 huiyuan.json,目录解析(逐级回退,目录不可写自动降级):
// HY_DB_DIR(向导/网页设置指定) → TRIM_DATA_SHARE_PATHS 第一项 → TRIM_PKGVAR/files → 本目录(本地开发)
// · 项目云数据落 DB_DIR/项目云/<手机号安全化>/<编号安全化>/{项目.zip,meta.json}
// · 配置持久化: TRIM_PKGETC/app.env(KEY=VALUE),与 cmd 脚本共用,存 HY_PORT/HY_DB_DIR
// · 客户端 API(与原版完全兼容):
// POST /api/login {手机号,密码} → {ok,会员类型,有效期} | {ok:false,msg}
// POST /api/register {手机号,密码} → {ok} 注册为"试用会员",有效期=今天+30天
// GET /api/member?手机号=xxx → {ok,手机号,会员类型,有效期}
// POST /api/changepwd {手机号,旧密码,新密码} → {ok}
// GET /api/list → {ok,会员:[...]}(管理页用)
// POST /api/save {会员:[...]} → {ok}(管理页整表保存)
// · 项目云 API(2026-09-16):
// POST /api/proj/upload body=JSON头 + \n\n + zip二进制 → {ok,编号,大小,时间}(覆盖式,上限 2GB)
// GET /api/proj/list?手机号= → {ok,项目:[{编号,名称,时间,大小}]}
// GET /api/proj/download?手机号=&编号= → zip 二进制流
// POST /api/proj/delete {手机号,编号} → {ok}
// 上传前校验会员已注册(huiyuan.json);手机号/编号文件名安全化+防路径穿越;
// 元数据存各项目目录 meta.json(项目名/上传时间/大小)。
// · 设置 API(网页设置页用):
// GET /api/settings → 端口/数据目录/数据库与项目云路径/默认目录/已授权目录/会员数
// POST /api/settings {dbdir} → 校验可写→迁移 huiyuan.json+项目云→回写 app.env
// 已授权目录经 Unix Socket 调开放 API trim.file.getSharedAccessibleFolders(失败优雅降级)
// · 安全(v1.4.0 双层加密通讯):
// 第1层 TLS:HTTPS 端口(自动=网页端口+1,可用 HY_API_PORT 覆盖)供客户端,
// 自签证书首次启动由 openssl 自动生成(存配置目录,升级保留/重装重生成)
// 第2层 信封加密(/api/enc,仅 HTTPS):客户端随机 AES-256 密钥加密业务 JSON,
// AES 密钥用 RSA-2048 公钥封装(OAEP),服务端私钥永不出服务器;
// HMAC-SHA256 验签防篡改;时间戳±5分钟+随机数缓存防重放
// 登录/注册/查会员/改密/项目云列表与删除走双层;项目 zip 上传下载走 TLS 单层(大文件)
// 客户端接口已从 HTTP 端口撤下(返回 TLS_REQUIRED 提示);HTTP 端口仅剩网页管理(管理密码保护)
// 管理接口(/api/list /api/save /api/settings /api/admin/changepwd)需管理密码登录:
// POST /api/admin/login 换 token,后续请求带 X-Admin-Token 头(24h 过期;自定义头同时天然防 CSRF)
// 登录/注册/管理登录失败按 IP 限速(1 分钟内失败≥10 次锁 60 秒)
// 注册校验手机号 11 位/密码 4-64 位,HY_REG_OPEN=0 可关闭开放注册(设置页开关)
// 上传配额每会员 1GB;整表保存与目录迁移前滚动备份 huiyuan.json.bak
// · 页面: / 或 /admin.html(会员管理) /xiangmu-cloud.html(项目云) /lib-cloud.html(库备份) /settings.html(设置)
var http = require('http');
var fs = require('fs');
var path = require('path');
var crypto = require('crypto');
var execFileSync = require('child_process').execFileSync;
var VERSION = '1.5.1';
var APPNAME = 'fnnas.huiyuan';
var HOST = process.env.MEMBER_HOST || '0.0.0.0';
var DB_NAME = 'huiyuan.json';
var PROJ_DIR_NAME = '项目云';
var UPLOAD_MAX = 2 * 1024 * 1024 * 1024; // 单次上传上限 2GB,防磁盘打满
var USER_QUOTA = 1024 * 1024 * 1024; // 每会员项目云总配额 1GB
// ---- 开放 API(应用共享授权):仅后端可通过 Unix Socket 调用 ----
var API_SOCKET = '/var/run/trim_open_gateway_apiscope.socket';
var API_PATH = '/api/v1/trimapp';
// ---- 端口 ----
var PORT = parseInt(process.env.HY_PORT || process.env.MEMBER_PORT || '', 10)
|| parseInt(process.env.TRIM_SERVICE_PORT || '', 10)
|| 12701;
var WWW = path.join(__dirname, '..', 'www');
// ---- 配置文件(TRIM_PKGETC/app.env,cmd 脚本与本服务共用) ----
var CFG_FILE = process.env.HY_CFG
|| (process.env.TRIM_PKGETC ? path.join(process.env.TRIM_PKGETC, 'app.env') : null);
function 读配置() {
var cfg = {};
if (!CFG_FILE) return cfg;
try {
fs.readFileSync(CFG_FILE, 'utf8').split(/\r?\n/).forEach(function (line) {
var m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (m) cfg[m[1]] = m[2];
});
} catch (e) { /* 配置不存在用默认值 */ }
return cfg;
}
function 写配置(cfg) {
if (!CFG_FILE) return; // 本地开发无配置文件,仅内存生效
var tmp = CFG_FILE + '.tmp';
fs.writeFileSync(tmp, Object.keys(cfg).map(function (k) { return k + '=' + (cfg[k] || ''); }).join('\n') + '\n', 'utf8');
fs.renameSync(tmp, CFG_FILE);
}
// ---- 管理密码鉴权(sha256(盐+密码) 存 app.env;token 在内存,重启失效) ----
function sha256hex(s) { return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); }
function 管理密码已设置() {
var cfg = 读配置();
return !!(cfg.ADMIN_PWD_SALT && cfg.ADMIN_PWD_SHA256);
}
function 管理密码正确(密码) {
var cfg = 读配置();
if (!cfg.ADMIN_PWD_SALT || !cfg.ADMIN_PWD_SHA256) return false;
var got = sha256hex(cfg.ADMIN_PWD_SALT + String(密码 || ''));
var want = String(cfg.ADMIN_PWD_SHA256).toLowerCase();
if (got.length !== want.length) return false;
try { return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want)); }
catch (e) { return false; }
}
function 设置管理密码(新密码) {
var salt = crypto.randomBytes(8).toString('hex');
var cfg = 读配置();
cfg.ADMIN_PWD_SALT = salt;
cfg.ADMIN_PWD_SHA256 = sha256hex(salt + 新密码);
写配置(cfg);
}
var TOKENS = new Map(); // token → 过期时间戳
var TOKEN_TTL = 24 * 3600 * 1000; // 24 小时
function 新token() {
var t = crypto.randomBytes(24).toString('hex');
TOKENS.set(t, Date.now() + TOKEN_TTL);
return t;
}
function token有效(t) {
if (!t || !TOKENS.has(t)) return false;
if (Date.now() > TOKENS.get(t)) { TOKENS.delete(t); return false; }
return true;
}
function 是管理员(req) { return token有效(req.headers['x-admin-token']); }
setInterval(function () {
var now = Date.now();
TOKENS.forEach(function (exp, t) { if (now > exp) TOKENS.delete(t); });
清理NONCES();
}, 3600 * 1000).unref();
// ---- 限速:按 key 记失败,1 分钟内失败≥10 次锁 60 秒(内存,重启清零) ----
var FAILS = new Map(); // key → {n, window, lock}
function 被锁秒(key) {
var f = FAILS.get(key);
return f && f.lock && Date.now() < f.lock ? Math.ceil((f.lock - Date.now()) / 1000) : 0;
}
function 失败记录(key) {
var now = Date.now();
var f = FAILS.get(key);
if (!f || now > f.window) f = { n: 0, window: now + 60000, lock: 0 };
f.n++;
if (f.n >= 10) f.lock = now + 60000;
FAILS.set(key, f);
}
function 失败清零(key) { FAILS.delete(key); }
// ---- 配置目录(TLS 证书/RSA 密钥存放处) ----
var CFG_DIR = CFG_FILE ? path.dirname(CFG_FILE) : __dirname;
// ---- RSA-2048 密钥对(信封加密;私钥永不出服务器,首次启动生成并持久化) ----
var RSA_PRIV_FILE = path.join(CFG_DIR, 'huiyuan_rsa.pem');
var RSA_KEY = null;
try {
if (!fs.existsSync(RSA_PRIV_FILE)) {
var kp = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
fs.writeFileSync(RSA_PRIV_FILE, kp.privateKey, { mode: 0o600 });
}
RSA_KEY = crypto.createPrivateKey(fs.readFileSync(RSA_PRIV_FILE));
} catch (e) { console.warn('RSA 密钥初始化失败(加密通道不可用): ' + e.message); }
function 公钥PEM() { return crypto.createPublicKey(RSA_KEY).export({ type: 'spki', format: 'pem' }).toString(); }
function 公钥指纹() { // SHA256 前 16 位 hex,供管理员核验公钥身份
var der = crypto.createPublicKey(RSA_KEY).export({ type: 'spki', format: 'der' });
return crypto.createHash('sha256').update(der).digest('hex').slice(0, 16).toUpperCase();
}
// ---- 信封加密(应用层第2层):RSA-OAEP 封装 AES-256 密钥 + AES-CBC 加密 + HMAC 验签 + 防重放 ----
var NONCES = new Map(); // nonce → 过期时间戳(防重放)
function 清理NONCES() {
var now = Date.now();
NONCES.forEach(function (exp, n) { if (now > exp) NONCES.delete(n); });
}
function 解密信封(payload, ip) {
try {
if (!RSA_KEY) return { err: '服务端加密未初始化' };
if (!payload || !payload.k || !payload.iv || !payload.data || !payload.mac || !payload.ts || !payload.nonce)
return { err: '信封字段缺失' };
var aesKey = crypto.privateDecrypt(
{ key: RSA_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' },
Buffer.from(payload.k, 'base64'));
if (aesKey.length !== 32) return { err: '会话密钥长度非法' };
var ts = Number(payload.ts);
if (!ts || Math.abs(Date.now() - ts) > 300000) return { err: '时间戳超出允许窗口(±5分钟)' };
if (NONCES.has(String(payload.nonce))) return { err: '重复的请求(疑似抓包重放),已拒绝' };
var want = crypto.createHmac('sha256', aesKey)
.update(payload.ts + '.' + payload.nonce + '.' + payload.iv + '.' + payload.data).digest('hex');
if (want !== String(payload.mac)) return { err: '签名校验失败(数据可能被篡改)' };
var d = crypto.createDecipheriv('aes-256-cbc', aesKey, Buffer.from(payload.iv, 'hex'));
var inner = JSON.parse(Buffer.concat([d.update(Buffer.from(payload.data, 'base64')), d.final()]).toString('utf8'));
return { aesKey: aesKey, inner: inner, nonce: String(payload.nonce) };
} catch (e) { return { err: '信封解密失败: ' + e.message }; }
}
function 加密响应(aesKey, obj) {
var ivb = crypto.randomBytes(16);
var c = crypto.createCipheriv('aes-256-cbc', aesKey, ivb);
var dataB = Buffer.concat([c.update(Buffer.from(JSON.stringify(obj), 'utf8')), c.final()]).toString('base64');
var iv = ivb.toString('hex');
var mac = crypto.createHmac('sha256', aesKey).update(iv + '.' + dataB).digest('hex');
return { ok: true, iv: iv, mac: mac, data: dataB };
}
// 信封内允许的业务接口(凭据类小数据;zip 上传下载走 TLS 单层)
var ENC_ROUTES = {
'/api/login': function (b, req, ip) { return 登录(b || {}, ip); },
'/api/register': function (b, req, ip) { return 注册(b || {}, ip); },
'/api/member': function (b) { return 查会员(b || {}); },
'/api/changepwd': function (b) { return 改密(b || {}); },
'/api/proj/list': function (b, req) {
if (!项目云鉴权(b['手机号'], b['密码'], req)) return { ok: false, msg: '手机号或密码错误' };
return 列项目(b['手机号']);
},
'/api/proj/delete': function (b, req) {
if (!项目云鉴权(b['手机号'], b['密码'], req)) return { ok: false, msg: '手机号或密码错误' };
var d3 = b['手机号'] && b['编号'] ? 项目目录(b['手机号'], b['编号']) : null;
if (!d3) return { ok: false, msg: '参数错误' };
try { fs.rmSync(d3, { recursive: true, force: true }); return { ok: true }; }
catch (e2) { return { ok: false, msg: '删除失败: ' + e2.message }; }
}
};
// ---- 数据库目录解析 ----
function 目录不可写原因(dir) {
try {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
var t = path.join(dir, '.huiyuan-write-test');
fs.writeFileSync(t, '1'); fs.unlinkSync(t);
return null; // 可写
} catch (e) { return (e.code ? e.code + ' ' : '') + e.message; }
}
function 目录可写(dir) { return 目录不可写原因(dir) === null; }
function 默认目录() {
var share = (process.env.TRIM_DATA_SHARE_PATHS || '').split(':')[0];
if (share) return share; // 应用数据目录(文件管理→应用文件)
if (process.env.TRIM_PKGVAR) return path.join(process.env.TRIM_PKGVAR, 'files');
return __dirname; // 本地开发回退
}
var DEFAULT_DIR = 默认目录();
var DB_DIR;
(function () {
var cands = [];
var cfgDbDir = 读配置().HY_DB_DIR || process.env.HY_DB_DIR || '';
if (cfgDbDir) cands.push(cfgDbDir);
cands.push(DEFAULT_DIR);
if (process.env.TRIM_PKGVAR) cands.push(path.join(process.env.TRIM_PKGVAR, 'files'));
cands.push(__dirname);
for (var i = 0; i < cands.length; i++) {
if (目录可写(cands[i])) { DB_DIR = cands[i]; return; }
}
DB_DIR = __dirname;
})();
var DB_FILE = path.join(DB_DIR, DB_NAME);
// 启动时确保数据库文件存在:缺失则落一个空会员库(安装包不带任何会员数据)
if (!fs.existsSync(DB_FILE)) {
try { 写库({ '会员': [] }); console.log('数据库不存在,已创建空会员库: ' + DB_FILE); }
catch (e) { console.warn('空数据库创建失败(首次保存时再写入): ' + e.message); }
}
// ---- 会员密码哈希(v1.3.0):存储格式 盐(32hex)$哈希(64hex),每会员独立随机盐 ----
// 协议不变:客户端仍传明文,服务端哈希后比对/存储
var 哈希格式 = /^[0-9a-f]{32}\$[0-9a-f]{64}$/;
function 哈希密码(明文) {
var salt = crypto.randomBytes(16).toString('hex');
return salt + '$' + sha256hex(salt + String(明文));
}
function 校验密码(存储值, 输入) {
var stored = String(存储值 || '');
var input = String(输入 === undefined || 输入 === null ? '' : 输入);
if (!stored) return false;
if (哈希格式.test(stored)) {
var i = stored.indexOf('$');
return sha256hex(stored.slice(0, i) + input) === stored.slice(i + 1);
}
return stored === input; // 迁移前的明文兜底
}
// 启动时把明文密码一次性升级为哈希(迁移前备份,成功后用哈希版刷新备份,磁盘不残留明文)
(function 升级哈希() {
try {
var d = 读库(), changed = 0;
d['会员'].forEach(function (m) {
if (m['密码'] && !哈希格式.test(String(m['密码']))) { m['密码'] = 哈希密码(m['密码']); changed++; }
});
if (changed) {
备份库(); // 迁移前备份(此刻 .bak 为明文版)
写库(d);
备份库(); // 用哈希版覆盖备份
console.log('已把 ' + changed + ' 个会员密码升级为哈希存储');
}
} catch (e) { console.warn('密码哈希迁移失败(保持原样,下次启动重试): ' + e.message); }
})();
// ---- 数据读写(原子写:临时文件+rename) ----
function 读库() {
try {
var d = JSON.parse(fs.readFileSync(DB_FILE, 'utf8'));
if (!Array.isArray(d['会员'])) d['会员'] = [];
return d;
} catch (e) { return { '会员': [] }; }
}
function 备份库() { // 整表保存/迁移前滚动备份上一版,防误操作丢数据
try {
if (fs.existsSync(DB_FILE)) {
// 不能用 copyFileSync 直接覆盖:飞牛 ACL 下已有文件可能是 000 权限,打开即 EACCES;
// 与 写库 一致走 tmp+rename(只需目录可写)
var tmp = DB_FILE + '.bak.tmp';
fs.writeFileSync(tmp, fs.readFileSync(DB_FILE));
fs.renameSync(tmp, DB_FILE + '.bak');
}
} catch (e) { console.warn('备份失败: ' + e.message); }
}
function 写库(data) {
var tmp = DB_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
fs.renameSync(tmp, DB_FILE);
}
function 今天加天数(n) {
var d = new Date(Date.now() + n * 86400000);
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
}
// ---- 业务 ----
function 登录(b, ip) {
var lock = 被锁秒('login:' + ip);
if (lock) return { ok: false, msg: '尝试过于频繁,请 ' + lock + ' 秒后再试' };
var db = 读库();
for (var i = 0; i < db['会员'].length; i++) {
var m = db['会员'][i];
if (m['手机号'] === b['手机号']) {
if (!校验密码(m['密码'], b['密码'])) { 失败记录('login:' + ip); return { ok: false, msg: '密码错误' }; }
失败清零('login:' + ip);
return { ok: true, 会员类型: m['会员类型'], 有效期: m['有效期'] };
}
}
失败记录('login:' + ip);
return { ok: false, msg: '该手机号未注册' };
}
function 注册(b, ip) {
var lock = 被锁秒('reg:' + ip);
if (lock) return { ok: false, msg: '尝试过于频繁,请 ' + lock + ' 秒后再试' };
if ((读配置().HY_REG_OPEN || '1') !== '1') return { ok: false, msg: '注册已关闭,请联系管理员开通' };
if (!/^1\d{10}$/.test(String(b['手机号'] || ''))) return { ok: false, msg: '手机号格式不正确(11 位数字)' };
var p = String(b['密码'] || '');
if (p.length < 4 || p.length > 64) return { ok: false, msg: '密码长度需 4-64 位' };
var db = 读库();
for (var i = 0; i < db['会员'].length; i++)
if (db['会员'][i]['手机号'] === b['手机号']) { 失败记录('reg:' + ip); return { ok: false, msg: '该手机号已注册' }; }
失败清零('reg:' + ip);
db['会员'].push({
'手机号': b['手机号'], '密码': 哈希密码(p),
'会员类型': '试用会员', '有效期': 今天加天数(30),
'注册时间': 今天加天数(0), '备注': ''
});
写库(db);
return { ok: true, msg: '注册成功' };
}
function 查会员(b) {
var db = 读库();
for (var i = 0; i < db['会员'].length; i++) {
var m = db['会员'][i];
if (m['手机号'] === b['手机号'])
return { ok: true, 手机号: m['手机号'], 会员类型: m['会员类型'], 有效期: m['有效期'] };
}
return { ok: false, msg: '该手机号未注册' };
}
function 改密(b) {
var db = 读库();
for (var i = 0; i < db['会员'].length; i++) {
var m = db['会员'][i];
if (m['手机号'] === b['手机号']) {
if (!校验密码(m['密码'], b['旧密码'])) return { ok: false, msg: '旧密码错误' };
if (!b['新密码']) return { ok: false, msg: '新密码不能为空' };
m['密码'] = 哈希密码(b['新密码']);
写库(db);
return { ok: true };
}
}
return { ok: false, msg: '该手机号未注册' };
}
// ---- 项目云(2026-09-16):数据落 DB_DIR/项目云/<手机号安全化>/<编号安全化>/{项目.zip,meta.json} ----
function 项目根() { return path.join(DB_DIR, PROJ_DIR_NAME); } // DB_DIR 可在设置页切换,须动态取
function 安全名(s) { // 文件名安全化:去掉路径分隔/非法字符,限长 80
var t = String(s || '').replace(/[\\\/:*?"<>|]/g, '_').replace(/\.\./g, '_').trim();
if (t.length > 80) t = t.slice(0, 80);
return t;
}
function 会员存在(手机号) {
var db = 读库();
for (var i = 0; i < db['会员'].length; i++)
if (db['会员'][i]['手机号'] === 手机号) return true;
return false;
}
function 会员密码正确(手机号, 密码) {
if (!密码) return false;
var db = 读库();
for (var i = 0; i < db['会员'].length; i++)
if (db['会员'][i]['手机号'] === 手机号) return 校验密码(db['会员'][i]['密码'], 密码);
return false;
}
// 项目云鉴权:管理 token 通过,或 会员手机号+密码 匹配(客户端需在各接口带上"密码")
function 项目云鉴权(手机号, 密码, req) {
return 是管理员(req) || 会员密码正确(手机号, 密码);
}
function 目录大小(dir) { // 递归估算目录总字节,读不了的部分按 0
var total = 0;
try {
fs.readdirSync(dir).forEach(function (name) {
var p = path.join(dir, name);
var st = fs.statSync(p);
if (st.isDirectory()) total += 目录大小(p);
else total += st.size;
});
} catch (e) { }
return total;
}
function 项目目录(手机号, 编号) { // 防穿越:拼好后必须仍在项目云根下
var root = 项目根();
var d = path.join(root, 安全名(手机号), 安全名(编号));
return d.indexOf(root) === 0 ? d : null;
}
function 列项目(手机号) {
if (!手机号 || !会员存在(手机号)) return { ok: false, msg: '该手机号未注册' };
var dir = 项目目录(手机号, '');
var 出 = [];
try {
fs.readdirSync(dir).forEach(function (编号) {
var p = path.join(dir, 编号);
try {
var meta = JSON.parse(fs.readFileSync(path.join(p, 'meta.json'), 'utf8'));
出.push({ 编号: meta['编号'] || 编号, 名称: meta['名称'] || '', 时间: meta['时间'] || '', 大小: meta['大小'] || 0 });
} catch (e) { }
});
} catch (e) { } // 目录不存在=无项目
return { ok: true, '项目': 出 };
}
// ---- 开放 API 调用(查管理员授权给本应用的目录) ----
function 查授权目录(cb) {
var token = process.env.TRIM_API_TOKEN;
if (!token) return cb({ ok: false, msg: '未获取到 TRIM_API_TOKEN(需从飞牛应用中心启动)' });
if (!fs.existsSync(API_SOCKET)) return cb({ ok: false, msg: '开放 API 接口不存在:需要 fnOS 1.2.0401 及以上、App 1.34.0 及以上' });
var body = JSON.stringify({ reqId: String(Date.now()), req: 'trim.file.getSharedAccessibleFolders', appName: APPNAME, data: {} });
var req = http.request({
socketPath: API_SOCKET, path: API_PATH, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'Content-Length': Buffer.byteLength(body) }
}, function (res) {
var chunks = [];
res.on('data', function (c) { chunks.push(c); });
res.on('end', function () {
try {
var j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (j.code === 0) return cb({ ok: true, paths: ((j.data || {}).paths || []).sort() });
cb({ ok: false, msg: j.msg || ('开放 API 返回 code=' + j.code) });
} catch (e) { cb({ ok: false, msg: '开放 API 响应异常: ' + e.message }); }
});
});
req.on('error', function (e) { cb({ ok: false, msg: '开放 API 调用失败: ' + e.message }); });
req.end(body);
}
// ---- 设置:查询 / 切换数据库目录 ----
function 设置信息(cb) {
查授权目录(function (auth) {
var db = 读库();
var cfgDir = 读配置().HY_DB_DIR || '';
cb({
ok: true, version: VERSION, port: PORT,
dbdir: DB_DIR, dbfile: DB_FILE, defaultdir: DEFAULT_DIR,
isDefaultDir: DB_DIR === DEFAULT_DIR,
configuredDir: cfgDir,
projroot: 项目根(),
authorized: auth.ok ? auth.paths : [],
authorizedOk: auth.ok, authorizedMsg: auth.ok ? '' : auth.msg,
count: db['会员'].length,
regOpen: (读配置().HY_REG_OPEN || '1') === '1',
adminConfigured: 管理密码已设置(),
tlsfp: TLS指纹(),
keyfp: RSA_KEY ? 公钥指纹() : ''
});
});
}
function 切换数据库目录(newDir, cb) {
if (typeof newDir !== 'string' || !/^\/.+/.test(newDir.trim()))
return cb({ ok: false, msg: '数据库目录必须是以 / 开头的完整路径' });
newDir = newDir.trim();
if (newDir === DB_DIR) return cb({ ok: true, dbdir: DB_DIR, dbfile: DB_FILE, msg: '目录未变化' });
var why = 目录不可写原因(newDir);
if (why) return cb({ ok: false, msg: '目标目录不可写(' + why + ')。共享目录需先在应用设置或本页「授权新目录」完成目录授权' });
var 旧文件 = DB_FILE;
var 旧项目根 = 项目根();
var 新文件 = path.join(newDir, DB_NAME);
var warn = '';
备份库(); // 迁移前备份
// 迁移顺序:先写新文件→回读校验→再删旧文件,任何一步失败都不丢数据
try {
if (fs.existsSync(旧文件)) {
var content = fs.readFileSync(旧文件);
fs.writeFileSync(新文件, content);
JSON.parse(fs.readFileSync(新文件, 'utf8')); // 新文件完整性校验
try { fs.unlinkSync(旧文件); }
catch (e) { warn = '旧文件暂无法删除,已保留在 ' + 旧文件 + ' (不影响使用)'; }
}
try { fs.unlinkSync(旧文件 + '.tmp'); } catch (e) {}
} catch (e) {
console.error('数据库迁移失败: ' + ((e.code ? e.code + ' ' : '') + e.message));
return cb({ ok: false, msg: '迁移失败: ' + ((e.code ? e.code + ' ' : '') + e.message) + ',原数据未受影响' });
}
DB_DIR = newDir; DB_FILE = 新文件;
// 新目录无库文件时落一个空会员库
try {
if (!fs.existsSync(DB_FILE)) 写库({ '会员': [] });
} catch (e) { console.warn('新目录空库初始化失败: ' + e.message); }
// 项目云整目录一并迁移(尽力而为:失败不回滚,旧数据保留并提示)
try {
if (fs.existsSync(旧项目根)) {
var 新项目根 = path.join(newDir, PROJ_DIR_NAME);
try { fs.renameSync(旧项目根, 新项目根); }
catch (e) { fs.cpSync(旧项目根, 新项目根, { recursive: true }); fs.rmSync(旧项目根, { recursive: true, force: true }); }
}
} catch (e) {
warn = (warn ? warn + ';' : '') + '项目云目录未能迁移,仍保留在 ' + 旧项目根 + ' (' + e.message + ')';
}
process.env.HY_DB_DIR = newDir;
try {
var cfg = 读配置(); cfg.HY_DB_DIR = newDir; 写配置(cfg);
} catch (e) {
console.error('配置回写失败: ' + e.message);
return cb({ ok: false, msg: '已迁移到 ' + DB_DIR + ' 但配置回写失败(重启后可能回退): ' + e.message });
}
console.log('数据库目录已切换: ' + 旧文件 + ' → ' + DB_FILE + (warn ? ' (' + warn + ')' : ''));
cb({ ok: true, dbdir: DB_DIR, dbfile: DB_FILE, msg: warn });
}
// ---- HTTP ----
function sendJson(res, obj) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(obj));
}
function readBody(req, cb) {
var chunks = [];
req.on('data', function (c) { chunks.push(c); });
req.on('end', function () {
try { cb(null, JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
catch (e) { cb(e, null); }
});
}
var MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.ico': 'image/x-icon'
};
var TLS_HINT = { ok: false, code: 'TLS_REQUIRED', msg: '该接口已迁移至加密通道,请升级客户端并使用 https://IP:端口 连接' };
function handler(req, res, isTLS) {
// 全站 HTTPS 安全响应头
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
var urlPath = decodeURIComponent(req.url.split('?')[0]);
var ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '') || 'unknown';
var query = {};
(req.url.split('?')[1] || '').split('&').forEach(function (kv) {
var p = kv.split('=');
if (p[0]) query[decodeURIComponent(p[0])] = decodeURIComponent((p[1] || '').replace(/\+/g, ' '));
});
// 公开:版本号(页面徽章用,不含敏感信息)
if (urlPath === '/api/version') return sendJson(res, { ok: true, version: VERSION });
// 公开:TLS 证书下载(安装到系统"受信任的根证书"后,浏览器/客户端不再有证书警告)
if (urlPath === '/api/cert') {
try {
var crt = fs.readFileSync(TLS_CRT_FILE);
res.writeHead(200, {
'Content-Type': 'application/x-x509-ca-cert',
'Content-Disposition': 'attachment; filename="fnnas-huiyuan.crt"',
'Content-Length': crt.length
});
return res.end(crt);
} catch (e) { return sendJson(res, { ok: false, msg: '证书读取失败: ' + e.message }); }
}
// ---- 加密通道(仅 HTTPS) ----
if (urlPath === '/api/pubkey') {
if (!isTLS) return sendJson(res, TLS_HINT);
if (!RSA_KEY) return sendJson(res, { ok: false, msg: '服务端加密未初始化' });
return sendJson(res, { ok: true, pubkey: 公钥PEM(), keyfp: 公钥指纹() });
}
if (urlPath === '/api/enc') {
if (!isTLS) return sendJson(res, TLS_HINT);
return readBody(req, function (e, payload) {
if (e || !payload || !payload.k) return sendJson(res, { ok: false, msg: '信封格式错误' });
var r = 解密信封(payload, ip);
if (r.err) return sendJson(res, { ok: false, msg: r.err });
NONCES.set(r.nonce, Date.now() + 600000); // 记录 nonce 防重放
var route = ENC_ROUTES[r.inner.path];
var result;
if (!route) result = { ok: false, msg: '不支持的加密接口: ' + r.inner.path };
else {
try { result = route(r.inner.body || {}, req, ip); }
catch (e2) { result = { ok: false, msg: '处理失败: ' + e2.message }; }
}
sendJson(res, 加密响应(r.aesKey, result));
});
}
// ---- 客户端会员接口:仅加密通道提供(HTTP 端口返回迁移提示) ----
var 仅TLS = (urlPath === '/api/login' || urlPath === '/api/register' || urlPath === '/api/member' ||
urlPath === '/api/changepwd');
if (仅TLS && !isTLS) return sendJson(res, TLS_HINT);
// 管理密码登录/登录态/改管理密码
if (urlPath === '/api/admin/login') return readBody(req, function (e, b) {
b = b || {};
var lock = 被锁秒('adm:' + ip);
if (lock) return sendJson(res, { ok: false, msg: '尝试过于频繁,请 ' + lock + ' 秒后再试' });
if (!管理密码已设置()) return sendJson(res, { ok: false, msg: '管理密码未设置(请在安装向导或应用设置中设置)' });
if (!管理密码正确(b['密码'])) { 失败记录('adm:' + ip); return sendJson(res, { ok: false, msg: '管理密码错误' }); }
失败清零('adm:' + ip);
return sendJson(res, { ok: true, token: 新token() });
});
if (urlPath === '/api/admin/check') return sendJson(res, { ok: 是管理员(req), adminConfigured: 管理密码已设置() });
if (urlPath === '/api/admin/addmember') return readBody(req, function (e, b) {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
b = b || {};
if (!/^1\d{10}$/.test(String(b['手机号'] || ''))) return sendJson(res, { ok: false, msg: '手机号格式不正确(11 位数字)' });
var p = String(b['密码'] || '');
if (p.length < 4 || p.length > 64) return sendJson(res, { ok: false, msg: '密码长度需 4-64 位' });
var db = 读库();
for (var i = 0; i < db['会员'].length; i++)
if (db['会员'][i]['手机号'] === b['手机号']) return sendJson(res, { ok: false, msg: '该手机号已注册' });
db['会员'].push({
'手机号': b['手机号'], '密码': 哈希密码(p),
'会员类型': String(b['会员类型'] || '试用会员'),
'有效期': String(b['有效期'] || 今天加天数(30)),
'注册时间': 今天加天数(0), '备注': String(b['备注'] || '')
});
备份库();
写库(db);
return sendJson(res, { ok: true, msg: '创建成功' });
});
// TLS 证书导入/信息/重置(管理登录)
if (urlPath === '/api/admin/tls/info') {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
try {
var crtText = fs.readFileSync(TLS_CRT_FILE, 'utf8');
var tlsInfo = 解析证书(crtText);
tlsInfo.source = (tlsInfo.subject.indexOf('fnnas-huiyuan') >= 0 && tlsInfo.issuer.indexOf('fnnas-huiyuan') >= 0) ? 'selfsigned' : 'custom';
return sendJson(res, { ok: true, tls: tlsInfo });
} catch (e) { return sendJson(res, { ok: false, msg: '证书读取失败: ' + e.message }); }
}
if (urlPath === '/api/admin/tls/import') return readBody(req, function (e, b) {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
b = b || {};
if (!b.cert || !b.key) return sendJson(res, { ok: false, msg: '请同时提供证书文件和私钥文件(PEM 格式)' });
var info;
try { info = 解析证书(b.cert); }
catch (e2) { return sendJson(res, { ok: false, msg: '证书文件无法解析(需 PEM 格式;证书链请用 fullchain 全链文件): ' + e2.message }); }
if (!密钥证书匹配(b.key, b.cert)) return sendJson(res, { ok: false, msg: '私钥与证书不匹配,请确认两个文件是同一套' });
try {
fs.writeFileSync(TLS_CRT_FILE, String(b.cert));
fs.writeFileSync(TLS_KEY_FILE, String(b.key), { mode: 0o600 });
console.log('已导入自定义 TLS 证书: ' + info.subject);
// 先响应本请求,再热切换监听(切换会断开旧连接,不能打断响应本身)
res.on('finish', function () { setTimeout(热重载证书, 200); });
return sendJson(res, { ok: true, tls: info, msg: '导入成功,已立即生效' });
} catch (e3) { return sendJson(res, { ok: false, msg: '写入证书失败: ' + e3.message }); }
});
if (urlPath === '/api/admin/tls/reset') {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
try {
生成自签证书();
res.on('finish', function () { setTimeout(热重载证书, 200); });
return sendJson(res, { ok: true, tls: 解析证书(fs.readFileSync(TLS_CRT_FILE, 'utf8')) });
} catch (e) { return sendJson(res, { ok: false, msg: '重置失败: ' + e.message }); }
}
if (urlPath === '/api/admin/changepwd') return readBody(req, function (e, b) {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
b = b || {};
var p = String(b['新密码'] || '');
if (p.length < 6) return sendJson(res, { ok: false, msg: '新管理密码至少 6 位' });
try { 设置管理密码(p); return sendJson(res, { ok: true }); }
catch (e2) { return sendJson(res, { ok: false, msg: '写入配置失败: ' + e2.message }); }
});
// (客户端会员接口 login/register/member/changepwd 已迁至加密通道 /api/enc,见上)
// 管理 API(需 X-Admin-Token;自定义头跨站请求带不了,同时天然防 CSRF)
if (urlPath === '/api/list') {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
var db = 读库();
return sendJson(res, { ok: true, '会员': db['会员'] });
}
if (urlPath === '/api/save') return readBody(req, function (e, b) {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
if (e || !b || !Array.isArray(b['会员'])) return sendJson(res, { ok: false, msg: '参数错误' });
// 密码语义:空=保持现有;明文=重置该会员密码(哈希入库);符合哈希格式=原样保留(管理页回传未改值)
var 旧库 = 读库();
var 旧密码 = {};
旧库['会员'].forEach(function (m) { if (m['手机号']) 旧密码[m['手机号']] = m['密码'] || ''; });
b['会员'].forEach(function (m, idx) {
var p = m['密码'];
if (!p) {
m['密码'] = 旧密码[m['手机号']] !== undefined ? 旧密码[m['手机号']]
: (旧库['会员'][idx] ? 旧库['会员'][idx]['密码'] || '' : '');
} else if (!哈希格式.test(String(p))) {
m['密码'] = 哈希密码(p);
}
});
备份库();
写库(b);
return sendJson(res, { ok: true });
});
// ---- 项目云路由(HTTPS:会员密码或管理 token;HTTP:仅管理 token,网页管理用) ----
// 上传:body = JSON 文本(项目编号/名称/手机号/密码) + "\n\n" + zip 二进制
if (urlPath === '/api/proj/upload') {
if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
var 收到 = 0, 超限 = false;
var chunks = [];
req.on('data', function (c) {
收到 += c.length;
if (收到 > UPLOAD_MAX) { 超限 = true; req.destroy(); return; }
chunks.push(c);
});
req.on('end', function () {
if (超限) return sendJson(res, { ok: false, msg: '超出上传上限 2GB' });
var buf = Buffer.concat(chunks);
var sep = buf.indexOf('\n\n');
if (sep < 0) return sendJson(res, { ok: false, msg: '缺少 JSON 头' });
var 头 = {};
try { 头 = JSON.parse(buf.slice(0, sep).toString('utf8')); } catch (e) { return sendJson(res, { ok: false, msg: 'JSON 头解析失败' }); }
var 手机号 = 头['手机号'], 编号 = 头['编号'];
if (!手机号 || !编号) return sendJson(res, { ok: false, msg: '缺少手机号或项目编号' });
if (!项目云鉴权(手机号, 头['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
var zip = buf.slice(sep + 2);
if (zip.length < 100) return sendJson(res, { ok: false, msg: 'zip 数据为空或过小' });
var d = 项目目录(手机号, 编号);
if (!d) return sendJson(res, { ok: false, msg: '路径非法' });
if (目录大小(项目目录(手机号, '')) + zip.length > USER_QUOTA)
return sendJson(res, { ok: false, msg: '云空间已满(每会员上限 1GB),请联系管理员清理' });
try {
fs.mkdirSync(d, { recursive: true });
var tmp = path.join(d, '项目.zip.tmp');
fs.writeFileSync(tmp, zip);
var 目标 = path.join(d, '项目.zip');
try { fs.unlinkSync(目标); } catch (e) { }
fs.renameSync(tmp, 目标);
var meta = { 编号: 编号, 名称: 头['名称'] || '', 时间: 头['时间'] || 今天加天数(0), 大小: zip.length };
var mt = path.join(d, 'meta.json.tmp');
fs.writeFileSync(mt, JSON.stringify(meta), 'utf8');
fs.renameSync(mt, path.join(d, 'meta.json'));
return sendJson(res, { ok: true, 编号: 编号, 大小: zip.length, 时间: meta['时间'] });
} catch (e) { return sendJson(res, { ok: false, msg: '写盘失败: ' + e.message }); }
});
return;
}
if (urlPath === '/api/proj/list') {
if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
if (!项目云鉴权(query['手机号'], query['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
return sendJson(res, 列项目(query['手机号']));
}
if (urlPath === '/api/proj/download') {
if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
var 手机号 = query['手机号'], 编号 = query['编号'];
if (!项目云鉴权(手机号, query['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
var d2 = 手机号 && 编号 ? 项目目录(手机号, 编号) : null;
var zp = d2 ? path.join(d2, '项目.zip') : '';
if (!d2 || !fs.existsSync(zp)) return sendJson(res, { ok: false, msg: '云上没有该项目' });
// 中文文件名只能放 filename*(UTF-8 百分号编码),ASCII fallback 防 latin-1 编码崩溃
var 下载名 = 安全名(编号) + '.zip';
var ascii名 = 下载名.replace(/[^\x20-\x7e]/g, '_');
res.writeHead(200, {
'Content-Type': 'application/zip', 'Content-Length': fs.statSync(zp).size,
'Content-Disposition': "attachment; filename=\"" + ascii名 + "\"; filename*=UTF-8''" + encodeURIComponent(下载名)
});
fs.createReadStream(zp).pipe(res);
return;
}
if (urlPath === '/api/proj/delete') return readBody(req, function (e, b) {
b = b || {};
if (!isTLS && !是管理员(req)) return sendJson(res, TLS_HINT);
if (!项目云鉴权(b['手机号'], b['密码'], req)) return sendJson(res, { ok: false, msg: '手机号或密码错误' });
var d3 = b['手机号'] && b['编号'] ? 项目目录(b['手机号'], b['编号']) : null;
if (!d3) return sendJson(res, { ok: false, msg: '参数错误' });
try { fs.rmSync(d3, { recursive: true, force: true }); return sendJson(res, { ok: true }); }
catch (e2) { return sendJson(res, { ok: false, msg: '删除失败: ' + e2.message }); }
});
// 设置 API(需管理登录)
if (urlPath === '/api/settings' && req.method === 'GET') {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
return 设置信息(function (j) { sendJson(res, j); });
}
if (urlPath === '/api/settings' && req.method === 'POST')
return readBody(req, function (e, b) {
if (!是管理员(req)) return sendJson(res, { ok: false, msg: '未登录或登录已过期' });
if (e || !b) return sendJson(res, { ok: false, msg: '参数错误' });
if (typeof b.regOpen === 'boolean') {
try {
var c = 读配置(); c.HY_REG_OPEN = b.regOpen ? '1' : '0'; 写配置(c);
return sendJson(res, { ok: true, regOpen: b.regOpen });
} catch (e2) { return sendJson(res, { ok: false, msg: '写入配置失败: ' + e2.message }); }
}
切换数据库目录(b.dbdir || '', function (j) { sendJson(res, j); });
});
// 静态文件(会员管理页/设置页)
if (urlPath === '/' || urlPath === '/admin.html') urlPath = '/admin.html';
var file = path.join(WWW, path.normalize(urlPath).replace(/^([A-Za-z]:)?[\\\/]+/, ''));
if (file.indexOf(WWW) !== 0) { res.writeHead(403); res.end('Forbidden'); return; }
fs.readFile(file, function (err, data) {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found: ' + urlPath); return; }
var ext = path.extname(file).toLowerCase();
// 禁止缓存:应用升级后浏览器必须取新页面,避免跑旧版脚本
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
res.end(data);
});
}
// ---- 全站 HTTPS(唯一监听;HTTP 明文通道彻底停用) ----
var TLS_KEY_FILE = path.join(CFG_DIR, 'tls.key');
var TLS_CRT_FILE = path.join(CFG_DIR, 'tls.crt');
function TLS指纹() { // 证书 SHA256 前 16 位,供管理员核验
try { return crypto.createHash('sha256').update(fs.readFileSync(TLS_CRT_FILE)).digest('hex').slice(0, 16).toUpperCase(); }
catch (e) { return ''; }
}
function 生成自签证书() {
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', TLS_KEY_FILE, '-out', TLS_CRT_FILE, '-days', '3650',
'-subj', '/CN=fnnas-huiyuan'], { stdio: 'ignore' });
console.log('已生成自签 TLS 证书: ' + TLS_CRT_FILE);
}
// 用 openssl 解析证书文本 → 结构化信息(导入校验与设置页展示用)
function 解析证书(pemCert) {
var tmp = path.join(CFG_DIR, '.import-cert.pem');
fs.writeFileSync(tmp, String(pemCert));
try {
var out = execFileSync('openssl', ['x509', '-in', tmp, '-noout',
'-subject', '-issuer', '-startdate', '-enddate', '-ext', 'subjectAltName'], { encoding: 'utf8' });
var info = { subject: '', issuer: '', notBefore: '', notAfter: '', san: '' };
out.split(/\r?\n/).forEach(function (line) {
if (line.indexOf('subject=') === 0) info.subject = line.slice(8).trim();
else if (line.indexOf('issuer=') === 0) info.issuer = line.slice(7).trim();
else if (line.indexOf('notBefore=') === 0) info.notBefore = line.slice(10).trim();
else if (line.indexOf('notAfter=') === 0) info.notAfter = line.slice(9).trim();
else if (line.indexOf('Subject Alternative Name') >= 0 && line.indexOf(':') >= 0)
info.san = line.slice(line.indexOf(':') + 1).trim();
});
info.fp = crypto.createHash('sha256').update(pemCert).digest('hex').slice(0, 16).toUpperCase();
return info;
} finally { try { fs.unlinkSync(tmp); } catch (e) { } }
}
// 校验私钥与证书匹配(公钥比对)
function 密钥证书匹配(pemKey, pemCert) {
var k = path.join(CFG_DIR, '.import-key.pem'), c = path.join(CFG_DIR, '.import-cert.pem');
fs.writeFileSync(k, String(pemKey)); fs.writeFileSync(c, String(pemCert));
try {
var pub1 = execFileSync('openssl', ['pkey', '-in', k, '-pubout'], { encoding: 'utf8' });
var pub2 = execFileSync('openssl', ['x509', '-in', c, '-noout', '-pubkey'], { encoding: 'utf8' });
return pub1.replace(/\s+/g, '') === pub2.replace(/\s+/g, '');
} finally { try { fs.unlinkSync(k); } catch (e) { } try { fs.unlinkSync(c); } catch (e2) { } }
}
var HTTPS_SERVER = null; // 当前 HTTPS 监听(导入/重置证书时整体热切换)
function TLS选项() {
return {
key: fs.readFileSync(TLS_KEY_FILE),
cert: fs.readFileSync(TLS_CRT_FILE),
SNICallback: function (servername, cb) { // 域名访问(SNI)握手期间动态取上下文,配合热切换
try { cb(null, require('tls').createSecureContext({ key: fs.readFileSync(TLS_KEY_FILE), cert: fs.readFileSync(TLS_CRT_FILE) })); }
catch (e) { cb(e); }
}
};
}
function 启动HTTPS监听(首次) {
var https = require('https');
var srv = https.createServer(TLS选项(), function (req, res) { handler(req, res, true); });
srv.on('error', function (e) {
var msg = 'HTTPS 监听失败: ' + e.message + (e.code === 'EADDRINUSE' ? '(端口被占用,可在应用设置中更换端口)' : '');
if (首次) { console.error(msg); process.exit(1); }
console.error(msg + ',1 秒后重试');
setTimeout(function () { try { 启动HTTPS监听(false); } catch (e2) { console.error(e2.message); } }, 1000);
});
HTTPS_SERVER = srv;
srv.listen(PORT, HOST, function () {
if (首次) {
console.log('会员系统服务 v' + VERSION + '(全站 HTTPS) on https://' + HOST + ':' + PORT
+ ' 数据库=' + DB_FILE + (DB_DIR === DEFAULT_DIR ? '(默认目录)' : '(自定义目录)')
+ ' 项目云=' + 项目根()
+ ' 默认目录=' + DEFAULT_DIR
+ ' 证书指纹=' + TLS指纹() + ' RSA指纹=' + (RSA_KEY ? 公钥指纹() : '未初始化'));
} else {
console.log('HTTPS 证书已热切换并重新监听,当前指纹=' + TLS指纹());
}
});
}
function 热重载证书() { // 导入/重置证书后调用:断开旧连接,用新证书重建监听
try { if (HTTPS_SERVER) { HTTPS_SERVER.closeAllConnections(); HTTPS_SERVER.close(); } } catch (e) { }
启动HTTPS监听(false);
}
(function 启动HTTPS() {
try {
if (!fs.existsSync(TLS_KEY_FILE) || !fs.existsSync(TLS_CRT_FILE)) 生成自签证书();
} catch (e) {
console.error('TLS 证书初始化失败(本应用强制全站 HTTPS,拒绝以明文启动): ' + e.message);
process.exit(1);
}
启动HTTPS监听(true);
})();
+13
View File
@@ -0,0 +1,13 @@
{
".url": {
"fnnas.huiyuan.Application": {
"title": "会员服务器",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "https",
"port": "${wizard_port}",
"url": "/",
"allUsers": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+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>