feat: 设置页新增「系统与更新」,按 GitHub 版本号检查更新
- 顶层 package.json 作为应用版本唯一来源(happy-export v1.0.0) - backend/lib/updater.js:查 GitHub Release(优先)或 tag,与本机版本做 semver 比对 - backend/routes/system.js:GET /api/system/info、GET /api/system/check-update(30s 缓存) - /api/health 版本号改为读真实版本 - 设置页新增第 4 个分类:当前版本 / 更新源仓库 / 检查更新(含更新说明折叠) - 新增 RefreshIcon
This commit is contained in:
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
+6
-1
@@ -3,6 +3,8 @@ const path = require('path');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const db = require('./db/db');
|
const db = require('./db/db');
|
||||||
|
const updater = require('./lib/updater');
|
||||||
|
const systemRoutes = require('./routes/system');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 4300;
|
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) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
version: '1.0.0',
|
version: updater.currentVersion(),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 系统信息与版本更新检查
|
||||||
|
app.use('/api/system', systemRoutes);
|
||||||
|
|
||||||
// 获取工作台数据
|
// 获取工作台数据
|
||||||
app.get('/api/dashboard', (req, res) => {
|
app.get('/api/dashboard', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -252,6 +252,16 @@ export const ImageIcon = ({ size = 18, className = '' }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/* 刷新 / 同步更新 */
|
||||||
|
export const RefreshIcon = ({ size = 18, className = '' }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||||
|
<path d="M3.2 12a8.8 8.8 0 0 1 15-6.2L21 8.4" />
|
||||||
|
<path d="M21 3.6v4.8h-4.8" />
|
||||||
|
<path d="M20.8 12a8.8 8.8 0 0 1-15 6.2L3 15.6" />
|
||||||
|
<path d="M3 20.4v-4.8h4.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
/* 删除 / 回收 */
|
/* 删除 / 回收 */
|
||||||
export const TrashIcon = ({ size = 16, className = '' }) => (
|
export const TrashIcon = ({ size = 16, className = '' }) => (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { MoonIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon } from './Icons'
|
import axios from 'axios'
|
||||||
|
import { MoonIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon } from './Icons'
|
||||||
|
|
||||||
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
|
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
|
||||||
const [activeCategory, setActiveCategory] = useState('appearance')
|
const [activeCategory, setActiveCategory] = useState('appearance')
|
||||||
@@ -56,6 +57,10 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
|
|||||||
<span className="settings-nav-icon"><SparkIcon size={18} /></span>
|
<span className="settings-nav-icon"><SparkIcon size={18} /></span>
|
||||||
<span>智能配置</span>
|
<span>智能配置</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button className={`settings-nav-item ${activeCategory === 'system-update' ? 'active' : ''}`} onClick={() => setActiveCategory('system-update')}>
|
||||||
|
<span className="settings-nav-icon"><RefreshIcon size={18} /></span>
|
||||||
|
<span>系统与更新</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="settings-sidebar-status"><TuneIcon size={15} /><span>专注业务,自在掌控<small>TRADE MANAGEMENT</small></span></div>
|
<div className="settings-sidebar-status"><TuneIcon size={15} /><span>专注业务,自在掌控<small>TRADE MANAGEMENT</small></span></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -64,6 +69,7 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
|
|||||||
{activeCategory === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
|
{activeCategory === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
|
||||||
{activeCategory === 'import-export' && <ImportExportSettings />}
|
{activeCategory === 'import-export' && <ImportExportSettings />}
|
||||||
{activeCategory === 'ai-config' && <AIConfigSettings />}
|
{activeCategory === 'ai-config' && <AIConfigSettings />}
|
||||||
|
{activeCategory === 'system-update' && <SystemUpdateSettings />}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<div className="settings-section">
|
||||||
|
<span className="section-label">[SYSTEM]</span>
|
||||||
|
<h3>系统与更新</h3>
|
||||||
|
<p className="settings-description">查看当前版本与运行环境,并从官方仓库检查是否有新版本。</p>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>当前版本</h4>
|
||||||
|
<div className="setting-info">
|
||||||
|
<span>
|
||||||
|
<strong>{info ? `happy 出口通 v${info.version}` : '正在读取版本…'}</strong>
|
||||||
|
<small>{info ? `运行环境 Node ${info.node} · ${info.platform} ${info.arch}` : '读取本机信息中'}</small>
|
||||||
|
</span>
|
||||||
|
<span className="preference-badge">正式版</span>
|
||||||
|
</div>
|
||||||
|
{info?.startedAt && <p className="help-text">服务启动于 {fmtTime(info.startedAt)}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>更新源</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>GitHub 仓库</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={repo}
|
||||||
|
placeholder="owner/repo"
|
||||||
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
|
disabled={checking}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="help-text">默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>检查更新</h4>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-primary" onClick={check} disabled={checking || loadingInfo}>
|
||||||
|
{checking ? '检查中…' : '检查更新'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!checking && result && (
|
||||||
|
<p className="help-text">
|
||||||
|
上次检查:{fmtTime(result.checkedAt)}
|
||||||
|
{result.cached ? '(结果来自缓存)' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{checking && (
|
||||||
|
<div className="update-status is-checking">
|
||||||
|
<span className="spinner" />
|
||||||
|
<span>正在连接 GitHub,请稍候…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!checking && error && (
|
||||||
|
<div className="update-status is-error">
|
||||||
|
<CloseIcon size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!checking && !error && result?.ok && result.hasUpdate && (
|
||||||
|
<>
|
||||||
|
<div className="update-status is-new">
|
||||||
|
<SparkIcon size={15} />
|
||||||
|
<span>
|
||||||
|
发现新版本 <b>v{result.latest}</b>,当前 v{result.current}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.publishedAt && <p className="help-text">发布于 {fmtTime(result.publishedAt)}</p>}
|
||||||
|
{(result.url || result.notes) && (
|
||||||
|
<div className="update-links">
|
||||||
|
{result.url && (
|
||||||
|
<a className="btn-link" href={result.url} target="_blank" rel="noreferrer">
|
||||||
|
在 GitHub 上查看
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{result.notes && (
|
||||||
|
<button className="btn-link" onClick={() => setShowNotes((v) => !v)}>
|
||||||
|
{showNotes ? '收起更新说明' : '查看更新说明'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showNotes && result.notes && <pre className="update-notes">{result.notes}</pre>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!checking && !error && result?.ok && !result.hasUpdate && (
|
||||||
|
<>
|
||||||
|
<div className="update-status is-ok">
|
||||||
|
<CheckIcon size={15} />
|
||||||
|
<span>已是最新版本(v{result.current})</span>
|
||||||
|
</div>
|
||||||
|
{result.note && <p className="help-text">{result.note}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Settings
|
export default Settings
|
||||||
|
|||||||
@@ -281,6 +281,18 @@
|
|||||||
.cart-total { align-self: center; color: var(--text-secondary); font-size: 13px; }
|
.cart-total { align-self: center; color: var(--text-secondary); font-size: 13px; }
|
||||||
.cart-total b { color: var(--text-primary); font-size: 16px; }
|
.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) {
|
@media (max-width: 720px) {
|
||||||
.batch-bar { flex-wrap: wrap; }
|
.batch-bar { flex-wrap: wrap; }
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user