diff --git a/backend/lib/updater.js b/backend/lib/updater.js new file mode 100644 index 0000000..cecc252 --- /dev/null +++ b/backend/lib/updater.js @@ -0,0 +1,216 @@ +/** + * happy 出口通 —— 系统版本检查 + * + * 与 sku-manager 的 updater 不同,这里**只做检查,不做自动替换**: + * happy 是整体应用(前端 dist + 后端服务 + 数据库),更新应当走 + * `git pull` + 重新构建 + 重启服务,而不是像 sku-manager 那样下载 zip + * 换版本目录。所以本模块只负责回答一个问题: + * 远端仓库的版本号 vs 本机版本号,谁新? + * + * 版本来源,按优先级: + * 1. GitHub Releases 的 latest.tag_name —— 推荐用法(打 tag 并发 Release) + * 2. 回退到 Tags 列表里最大的版本号 —— 只打了 tag 没发 Release 也能识别 + * + * 默认仓库 Drsakura/happy-export,可用 HAPPY_UPDATE_REPO 覆盖(接私有仓库需另配 token)。 + */ +const fs = require('fs'); +const path = require('path'); + +const APP_ROOT = path.join(__dirname, '..', '..'); +const DEFAULT_REPO = 'Drsakura/happy-export'; +const API_BASE = 'https://api.github.com'; +const TIMEOUT_MS = 8000; +const USER_AGENT = 'happy-export-updater'; + +/* ---------------------------- 版本号处理 ---------------------------- */ + +/** + * 把 "v1.2.3" / "1.2.3" / "1.2.3-beta.1" 解析成可比较的结构。 + * 不是合法 semver 就返回 null(宁可不报更新,也不误报)。 + */ +function parseVersion(input) { + const m = String(input == null ? '' : input) + .trim() + .replace(/^v/i, '') + .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); + if (!m) return null; + return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || '' }; +} + +/** 清洗 tag:去掉 v 前缀与空白,返回标准化版本字符串或 null。 */ +function normalizeTag(tag) { + const p = parseVersion(tag); + return p ? p.nums.join('.') + (p.pre ? '-' + p.pre : '') : null; +} + +/** + * a > b → 1;a < b → -1;相等或无法解析 → 0。 + * 无法解析时返回 0 是刻意的:宁可说「已是最新」,也不要因为格式怪就误报更新。 + */ +function compareVersions(a, b) { + const va = parseVersion(a); + const vb = parseVersion(b); + if (!va || !vb) return 0; + + for (let i = 0; i < 3; i += 1) { + if (va.nums[i] !== vb.nums[i]) return va.nums[i] > vb.nums[i] ? 1 : -1; + } + if (va.pre === vb.pre) return 0; + // 预发布版低于同号正式版:1.0.0-beta < 1.0.0 + if (!va.pre) return 1; + if (!vb.pre) return -1; + return va.pre > vb.pre ? 1 : -1; +} + +/** 当前应用版本。取自顶层 package.json —— 与应用清单同一个来源,避免两处版本号打架。 */ +function currentVersion() { + const candidates = [ + path.join(APP_ROOT, 'package.json'), + path.join(__dirname, '..', 'package.json') + ]; + for (const file of candidates) { + try { + const version = JSON.parse(fs.readFileSync(file, 'utf8')).version; + if (version) return version; + } catch { + /* 读不到就试下一个候选 */ + } + } + return '0.0.0'; +} + +/** 允许用户填 "owner/repo"、完整 URL、或带 .git 的地址;非法则返回 null。 */ +function normalizeRepo(repo) { + const slug = String(repo == null ? '' : repo) + .trim() + .replace(/^https?:\/\/github\.com\//i, '') + .replace(/\.git$/i, '') + .replace(/\/+$/, ''); + return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null; +} + +/* ---------------------------- 网络请求 ---------------------------- */ + +function githubError(status) { + if (status === 404) return '找不到仓库或版本,请检查更新地址是否正确(私有仓库需要额外配置访问令牌)'; + if (status === 401) return '访问令牌无效或已过期'; + if (status === 403) return 'GitHub 接口被限流(403),稍后再试'; + if (status >= 500) return `GitHub 服务异常(${status}),稍后再试`; + return `GitHub 返回 ${status}`; +} + +/** 带超时的 GET。返回 { data } | { missing:true }(404)| { error }。 */ +async function getJson(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const resp = await fetch(url, { + headers: { 'User-Agent': USER_AGENT, Accept: 'application/vnd.github+json' }, + signal: controller.signal + }); + if (resp.ok) return { data: await resp.json() }; + if (resp.status === 404) return { missing: true }; + return { error: githubError(resp.status) }; + } catch (e) { + const why = e.name === 'AbortError' ? '请求超时' : e.message; + return { error: `连不上 GitHub(${why}),请检查网络后重试` }; + } finally { + clearTimeout(timer); + } +} + +/* ---------------------------- 主流程 ---------------------------- */ + +/** + * 检查是否有新版本。 + * 从不抛异常 —— 所有失败都以 { ok:false, error } 形式返回,方便前端直接展示。 + */ +async function checkUpdate(repoInput) { + const repo = normalizeRepo(repoInput) || DEFAULT_REPO; + const current = currentVersion(); + const base = { repo, current, checkedAt: new Date().toISOString() }; + + // 1) 优先看 Releases + const release = await getJson(`${API_BASE}/repos/${repo}/releases/latest`); + if (release.error) return { ...base, ok: false, error: release.error }; + + if (release.data) { + const tag = String(release.data.tag_name || ''); + const latest = normalizeTag(tag); + if (!latest) { + return { ...base, ok: false, error: `最新 Release 的标签不是版本号:${tag || '(空)'}` }; + } + return { + ...base, + ok: true, + source: 'release', + latest, + tag, + hasUpdate: compareVersions(latest, current) > 0, + title: String(release.data.name || '').trim(), + notes: String(release.data.body || '').slice(0, 2000), + publishedAt: release.data.published_at || null, + url: release.data.html_url || null + }; + } + + // 2) 没有 Release,回退看 Tags(只打 tag 也能识别更新) + const tags = await getJson(`${API_BASE}/repos/${repo}/tags?per_page=100`); + if (tags.error) return { ...base, ok: false, error: tags.error }; + if (tags.missing) return { ...base, ok: false, error: githubError(404) }; + + const list = Array.isArray(tags.data) ? tags.data : []; + const versions = list + .map((t) => normalizeTag(t && t.name)) + .filter(Boolean) + .sort(compareVersions) + .reverse(); + + if (!versions.length) { + return { + ...base, + ok: true, + source: 'none', + latest: current, + hasUpdate: false, + note: '远端仓库还没有版本标签(tag)。在 GitHub 上发布 v1.0.1 这类 Release 或 tag 后,这里就能检查到更新。' + }; + } + + const latest = versions[0]; + return { + ...base, + ok: true, + source: 'tag', + latest, + tag: latest, + hasUpdate: compareVersions(latest, current) > 0, + title: '', + notes: '', + publishedAt: null, + url: `https://github.com/${repo}/releases/tag/${latest}` + }; +} + +/** 本机运行环境摘要,给设置页「系统与更新」展示用。 */ +function systemInfo() { + return { + name: 'happy 出口通', + version: currentVersion(), + repo: process.env.HAPPY_UPDATE_REPO || DEFAULT_REPO, + node: process.version, + platform: process.platform === 'win32' ? 'Windows' : process.platform, + arch: process.arch, + uptimeSeconds: Math.round(process.uptime()), + startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString() + }; +} + +module.exports = { + checkUpdate, + currentVersion, + systemInfo, + compareVersions, + normalizeRepo, + DEFAULT_REPO +}; diff --git a/backend/routes/system.js b/backend/routes/system.js new file mode 100644 index 0000000..37c55ed --- /dev/null +++ b/backend/routes/system.js @@ -0,0 +1,48 @@ +/** + * 系统信息与版本更新检查。 + * 挂载点:/api/system + */ +const express = require('express'); +const updater = require('../lib/updater'); + +const router = express.Router(); + +// 简易缓存:GitHub 未认证请求每小时只有 60 次配额,连续点「检查更新」不该把额度刷完。 +const CACHE_TTL_MS = 30 * 1000; +let cache = { key: '', at: 0, payload: null }; + +/** + * GET /api/system/info + * 当前版本与运行环境。轻量、不发外部请求,设置页打开时就能调。 + */ +router.get('/info', (req, res) => { + try { + res.json(updater.systemInfo()); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/system/check-update?repo=owner/repo&force=1 + * 对比远端仓库版本与本机版本。repo 省略时用默认仓库(Drsakura/happy-export)。 + */ +router.get('/check-update', async (req, res) => { + try { + const repo = updater.normalizeRepo(req.query.repo) || updater.DEFAULT_REPO; + const force = String(req.query.force || '') === '1'; + + if (!force && cache.payload && cache.key === repo && Date.now() - cache.at < CACHE_TTL_MS) { + return res.json({ ...cache.payload, cached: true }); + } + + const result = await updater.checkUpdate(repo); + cache = { key: repo, at: Date.now(), payload: result }; + res.json({ ...result, cached: false }); + } catch (error) { + // checkUpdate 内部已兜底,走到这里说明是意料之外的问题 + res.status(500).json({ ok: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index bb3b511..ca483ba 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,8 @@ const path = require('path'); const cors = require('cors'); const http = require('http'); const db = require('./db/db'); +const updater = require('./lib/updater'); +const systemRoutes = require('./routes/system'); const app = express(); const PORT = process.env.PORT || 4300; @@ -71,11 +73,14 @@ app.use(express.static(path.join(__dirname, '../frontend/dist'))); app.get('/api/health', (req, res) => { res.json({ status: 'ok', - version: '1.0.0', + version: updater.currentVersion(), timestamp: new Date().toISOString() }); }); +// 系统信息与版本更新检查 +app.use('/api/system', systemRoutes); + // 获取工作台数据 app.get('/api/dashboard', (req, res) => { try { diff --git a/frontend/src/components/Icons.jsx b/frontend/src/components/Icons.jsx index fac1c41..9051181 100644 --- a/frontend/src/components/Icons.jsx +++ b/frontend/src/components/Icons.jsx @@ -252,6 +252,16 @@ export const ImageIcon = ({ size = 18, className = '' }) => ( ) +/* 刷新 / 同步更新 */ +export const RefreshIcon = ({ size = 18, className = '' }) => ( + +) + /* 删除 / 回收 */ export const TrashIcon = ({ size = 16, className = '' }) => ( 智能配置 +
专注业务,自在掌控TRADE MANAGEMENT
@@ -64,6 +69,7 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu {activeCategory === 'appearance' && } {activeCategory === 'import-export' && } {activeCategory === 'ai-config' && } + {activeCategory === 'system-update' && } @@ -127,4 +133,166 @@ function AIConfigSettings() { ) } +/* --------------------------------------------------------------------------- + 系统与更新 + 显示本机版本与运行环境,并从 GitHub 仓库检查是否有新版本。 + 版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。 +--------------------------------------------------------------------------- */ +function SystemUpdateSettings() { + const [info, setInfo] = useState(null) + const [repo, setRepo] = useState('') + const [loadingInfo, setLoadingInfo] = useState(true) + const [checking, setChecking] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState('') + const [showNotes, setShowNotes] = useState(false) + + useEffect(() => { + let alive = true + axios + .get('/api/system/info') + .then(({ data }) => { + if (!alive) return + setInfo(data) + setRepo(data.repo || '') + }) + .catch((e) => { + if (alive) setError(e?.response?.data?.error || e.message) + }) + .finally(() => { + if (alive) setLoadingInfo(false) + }) + return () => { + alive = false + } + }, []) + + const check = async () => { + setChecking(true) + setError('') + setResult(null) + setShowNotes(false) + try { + const { data } = await axios.get('/api/system/check-update', { params: { repo, force: 1 } }) + setResult(data) + if (!data.ok) setError(data.error || '检查更新失败') + } catch (e) { + setError(e?.response?.data?.error || e.message) + } finally { + setChecking(false) + } + } + + const fmtTime = (value) => { + if (!value) return '' + try { + return new Date(value).toLocaleString('zh-CN', { hour12: false }) + } catch { + return String(value) + } + } + + return ( +
+ [SYSTEM] +

系统与更新

+

查看当前版本与运行环境,并从官方仓库检查是否有新版本。

+ +
+

当前版本

+
+ + {info ? `happy 出口通 v${info.version}` : '正在读取版本…'} + {info ? `运行环境 Node ${info.node} · ${info.platform} ${info.arch}` : '读取本机信息中'} + + 正式版 +
+ {info?.startedAt &&

服务启动于 {fmtTime(info.startedAt)}

} +
+ +
+

更新源

+
+ + setRepo(e.target.value)} + disabled={checking} + /> +
+

默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。

+
+ +
+

检查更新

+
+ +
+ {!checking && result && ( +

+ 上次检查:{fmtTime(result.checkedAt)} + {result.cached ? '(结果来自缓存)' : ''} +

+ )} + + {checking && ( +
+ + 正在连接 GitHub,请稍候… +
+ )} + + {!checking && error && ( +
+ + {error} +
+ )} + + {!checking && !error && result?.ok && result.hasUpdate && ( + <> +
+ + + 发现新版本 v{result.latest},当前 v{result.current} + +
+ {result.publishedAt &&

发布于 {fmtTime(result.publishedAt)}

} + {(result.url || result.notes) && ( +
+ {result.url && ( + + 在 GitHub 上查看 + + )} + {result.notes && ( + + )} +
+ )} + {showNotes && result.notes &&
{result.notes}
} + + )} + + {!checking && !error && result?.ok && !result.hasUpdate && ( + <> +
+ + 已是最新版本(v{result.current}) +
+ {result.note &&

{result.note}

} + + )} +
+
+ ) +} + export default Settings diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 3ea5571..9dd1829 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -281,6 +281,18 @@ .cart-total { align-self: center; color: var(--text-secondary); font-size: 13px; } .cart-total b { color: var(--text-primary); font-size: 16px; } +/* 设置页:系统与更新 */ +.update-status { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 13px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-size: 13px; line-height: 1.5; } +.update-status b { color: var(--text-primary); font-family: var(--font-mono); } +.update-status .spinner { width: 14px; height: 14px; border-width: 2px; } +.update-status.is-checking { color: var(--text-tertiary); } +.update-status.is-ok { border-color: var(--success-border); background: var(--success-bg); color: var(--success); } +.update-status.is-new { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); } +.update-status.is-error { border-color: var(--danger-border); background: var(--danger-bg); color: var(--danger); } +.update-links { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 10px; } +.update-links .btn-link { text-decoration: none; font-size: 13px; } +.update-notes { margin: 10px 0 0; max-height: 230px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--text-secondary); font-family: var(--font-body); font-size: 12px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; } + /* 窄屏:批量条与抽屉内表单换行 */ @media (max-width: 720px) { .batch-bar { flex-wrap: wrap; } diff --git a/package.json b/package.json new file mode 100644 index 0000000..9016bfd --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "happy-export", + "version": "1.0.0", + "private": true, + "description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)", + "repository": { + "type": "git", + "url": "git+https://github.com/Drsakura/happy-export.git" + }, + "scripts": { + "dev": "cd frontend && npm run dev", + "start": "cd backend && npm start" + }, + "license": "UNLICENSED" +}