feat: add in-app update notification with GitHub/Gitee fallback
This commit is contained in:
1 parent
e327279d47
commit
2d30f27e14
9 files changed
+315
No files matched your search
@@ -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**:
|
||||
|
||||
@@ -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 供应商,你可以通过 **环境变量** 或 **应用内设置** 来配置:
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4768,3 +4768,79 @@ h5.chat-md-heading {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -403,5 +403,11 @@
|
||||
"allCheck": "Выбрать все",
|
||||
"allUncheck": "Очистить все",
|
||||
"reset": "Сброс"
|
||||
},
|
||||
"update": {
|
||||
"newVersion": "Доступна новая версия {version}!",
|
||||
"viewSource": "Исходный код",
|
||||
"downloadClient": "Скачать клиент",
|
||||
"dismiss": "Скрыть"
|
||||
}
|
||||
}
|
||||
@@ -403,5 +403,11 @@
|
||||
"allCheck": "全选",
|
||||
"allUncheck": "全不选",
|
||||
"reset": "重置"
|
||||
},
|
||||
"update": {
|
||||
"newVersion": "发现新版本 {version}!",
|
||||
"viewSource": "查看源码",
|
||||
"downloadClient": "下载客户端",
|
||||
"dismiss": "忽略"
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
Reference in new issue
Block a user