feat: add in-app update notification with GitHub/Gitee fallback

This commit is contained in:
YuanShiJiLoong committed 2026-02-25 00:31:25 +09:00
1 parent e327279d47
commit 2d30f27e14
9 files changed
+315

No files matched your search

+20
View File
@@ -123,6 +123,26 @@ npm start
---
## 🔄 Updating
### Desktop Client Users
Download the latest installer from the [Releases](https://github.com/YuanShiJiLoong/author/releases/latest) page and install it over your current version. Your data is stored in the browser/Electron profile and will not be lost.
### Self-Deployed Users (Source)
```bash
git pull origin main
npm install
npm run dev
```
### Vercel Users
If you deployed via Vercel fork, just sync your fork with upstream on GitHub — Vercel will automatically redeploy.
---
## ⚙️ AI Configuration
Author supports multiple AI providers. Configure via **environment variables** or **in-app settings**:
+22
View File
@@ -124,6 +124,28 @@ npm start
---
## 🔄 更新
### 桌面客户端用户
前往 [Releases](https://github.com/YuanShiJiLoong/author/releases/latest) 页面下载最新版本安装包,覆盖安装即可。你的数据存储在浏览器/Electron 用户配置中,不会丢失。
> 💬 无法访问 GitHub?[加入 QQ 交流群:1087016949](https://qm.qq.com/q/wjRDkotw0E),群文件中下载最新版本。
### 源码部署用户
```bash
git pull origin main
npm install
npm run dev
```
### Vercel 部署用户
如果你通过 Fork 部署到 Vercel,只需在 GitHub 上将你的 Fork 与上游同步(Sync fork),Vercel 会自动重新部署。
---
## ⚙️ AI 配置
Author 支持多种 AI 供应商,你可以通过 **环境变量** 或 **应用内设置** 来配置:
+98
View File
@@ -0,0 +1,98 @@
import { NextResponse } from 'next/server';
import { readFileSync } from 'fs';
import { join } from 'path';
/**
* 比较两个语义化版本号
* 如果 a > b 返回 1,a < b 返回 -1,相等返回 0
*/
function compareSemver(a, b) {
const pa = a.replace(/^v/, '').split('.').map(Number);
const pb = b.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
}
/**
* 从 GitHub 或 Gitee 获取最新 Release 版本号
* 优先 GitHub,超时/失败后回退到 Gitee
*/
async function fetchLatestVersion() {
const sources = [
{
name: 'GitHub',
url: 'https://api.github.com/repos/YuanShiJiLoong/author/releases/latest',
headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'Author-App' },
extract: (data) => data.tag_name,
},
{
name: 'Gitee',
url: 'https://gitee.com/api/v5/repos/yuanshijilong/author/releases/latest',
headers: {},
extract: (data) => data.tag_name,
},
];
for (const source of sources) {
try {
const res = await fetch(source.url, {
headers: source.headers,
signal: AbortSignal.timeout(5000), // 5 秒超时
});
if (!res.ok) continue;
const data = await res.json();
const tag = source.extract(data);
if (tag) return tag;
} catch {
// 当前源失败,尝试下一个
continue;
}
}
return null; // 所有源都失败
}
export async function GET() {
try {
// 读取当前版本号
let currentVersion;
try {
const pkgPath = join(process.cwd(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
currentVersion = pkg.version;
} catch {
return NextResponse.json(
{ error: '无法读取当前版本号' },
{ status: 500 }
);
}
// 获取最新版本
const latestTag = await fetchLatestVersion();
if (!latestTag) {
return NextResponse.json(
{ current: currentVersion, latest: null, hasUpdate: false },
{ headers: { 'Cache-Control': 'public, max-age=600' } } // 失败时缓存 10 分钟
);
}
const latestVersion = latestTag.replace(/^v/, '');
const hasUpdate = compareSemver(latestVersion, currentVersion) > 0;
return NextResponse.json(
{ current: currentVersion, latest: latestVersion, hasUpdate },
{ headers: { 'Cache-Control': 'public, max-age=3600' } } // 成功缓存 1 小时
);
} catch (err) {
return NextResponse.json(
{ error: '检查更新失败', details: err.message },
{ status: 500 }
);
}
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useState, useEffect } from 'react';
import { useI18n } from '../lib/useI18n';
export default function UpdateBanner() {
const { t } = useI18n();
const [updateInfo, setUpdateInfo] = useState(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const checkUpdate = async () => {
try {
const res = await fetch('/api/check-update', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (data.hasUpdate && data.latest) {
// 检查用户是否已经忽略过该版本
const dismissedVersion = localStorage.getItem('author-update-dismissed');
if (dismissedVersion === data.latest) return;
setUpdateInfo(data);
}
} catch {
// 网络失败静默跳过
}
};
// 延迟 3 秒检查,避免影响首屏加载
const timer = setTimeout(checkUpdate, 3000);
return () => clearTimeout(timer);
}, []);
const handleDismiss = () => {
setDismissed(true);
if (updateInfo?.latest) {
localStorage.setItem('author-update-dismissed', updateInfo.latest);
}
};
if (!updateInfo || dismissed) return null;
const versionText = t('update.newVersion').replace('{version}', `v${updateInfo.latest}`);
return (
<div className="update-banner">
<div className="update-banner-content">
<span className="update-banner-icon">🔔</span>
<span className="update-banner-text">{versionText}</span>
<a
href="https://github.com/YuanShiJiLoong/author"
target="_blank"
rel="noopener noreferrer"
className="update-banner-link"
>
{t('update.viewSource')}
</a>
<a
href="https://github.com/YuanShiJiLoong/author/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="update-banner-link"
>
{t('update.downloadClient')}
</a>
<button
className="update-banner-dismiss"
onClick={handleDismiss}
title={t('update.dismiss')}
>
✕
</button>
</div>
</div>
);
}
+76
View File
@@ -4767,4 +4767,80 @@ h5.chat-md-heading {
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ==================== 更新提示条 ==================== */
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
animation: updateBannerSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.update-banner-content {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 20px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: var(--text-inverse);
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
}
.update-banner-icon {
font-size: 16px;
flex-shrink: 0;
}
.update-banner-text {
white-space: nowrap;
}
.update-banner-link {
color: var(--text-inverse);
text-decoration: underline;
text-underline-offset: 2px;
font-weight: 600;
transition: opacity 0.15s;
white-space: nowrap;
}
.update-banner-link:hover {
opacity: 0.85;
}
.update-banner-dismiss {
background: none;
border: none;
color: var(--text-inverse);
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.15s;
line-height: 1;
flex-shrink: 0;
}
.update-banner-dismiss:hover {
opacity: 1;
}
@keyframes updateBannerSlideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
+6
View File
@@ -403,5 +403,11 @@
"allCheck": "Select All",
"allUncheck": "Clear All",
"reset": "Reset"
},
"update": {
"newVersion": "New version {version} available!",
"viewSource": "View Source",
"downloadClient": "Download Client",
"dismiss": "Dismiss"
}
}
+6
View File
@@ -403,5 +403,11 @@
"allCheck": "Выбрать все",
"allUncheck": "Очистить все",
"reset": "Сброс"
},
"update": {
"newVersion": "Доступна новая версия {version}!",
"viewSource": "Исходный код",
"downloadClient": "Скачать клиент",
"dismiss": "Скрыть"
}
}
+6
View File
@@ -403,5 +403,11 @@
"allCheck": "全选",
"allUncheck": "全不选",
"reset": "重置"
},
"update": {
"newVersion": "发现新版本 {version}!",
"viewSource": "查看源码",
"downloadClient": "下载客户端",
"dismiss": "忽略"
}
}
+4
View File
@@ -28,6 +28,7 @@ const TourOverlay = dynamic(() => import('./components/TourOverlay'), { ssr: fal
const AiSidebar = dynamic(() => import('./components/AiSidebar'), { ssr: false });
const SnapshotManager = dynamic(() => import('./components/SnapshotManager'), { ssr: false });
const WelcomeModal = dynamic(() => import('./components/WelcomeModal'), { ssr: false });
const UpdateBanner = dynamic(() => import('./components/UpdateBanner'), { ssr: false });
export default function Home() {
const [showGitMenu, setShowGitMenu] = useState(false);
@@ -258,6 +259,9 @@ export default function Home() {
return (
<div className="app-layout">
{/* ===== 更新提示 ===== */}
<UpdateBanner />
{/* ===== 侧边栏 ===== */}
<Sidebar />