mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-03 15:08:25 +00:00
Merge remote-tracking branch 'upstream/dev' into dev
This commit is contained in:
@@ -75,6 +75,8 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -172,6 +174,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.year-btn {
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,4 +401,4 @@
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||
import './JumpToDateDialog.scss'
|
||||
|
||||
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
messageDates,
|
||||
loadingDates = false
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(
|
||||
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
|
||||
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
}
|
||||
|
||||
const openMonthView = () => setViewMode('month')
|
||||
const openYearView = () => {
|
||||
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||
setViewMode('year')
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (viewMode === 'day') {
|
||||
openMonthView()
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
openYearView()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev - 12)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev + 12)
|
||||
}
|
||||
|
||||
const navTitle = viewMode === 'day'
|
||||
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||
: viewMode === 'month'
|
||||
? `${calendarDate.getFullYear()}年`
|
||||
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||
|
||||
return (
|
||||
<div className="jump-date-overlay" onClick={onClose}>
|
||||
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||
onClick={handleTitleClick}
|
||||
type="button"
|
||||
>
|
||||
{navTitle}
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={handleNext}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showYearMonthPicker ? (
|
||||
{viewMode === 'month' ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||
{monthNames.map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setShowYearMonthPicker(false)
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setViewMode('day')
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'year' ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-grid">
|
||||
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||
setViewMode('month')
|
||||
}}
|
||||
>
|
||||
{year}年
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||
{loadingDates && (
|
||||
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
const d = new Date()
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>今天</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 7)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一周前</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一月前</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month.clickable {
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month.clickable:hover {
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.jump-date-popover .nav-btn {
|
||||
@@ -83,6 +97,37 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-grid,
|
||||
.jump-date-popover .year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
min-height: 256px;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell,
|
||||
.jump-date-popover .year-cell {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell:hover,
|
||||
.jump-date-popover .year-cell:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell.active,
|
||||
.jump-date-popover .year-cell.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell {
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -31,14 +31,20 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
loadingDates = false,
|
||||
loadingDateCounts = false
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(getYearPageStart(new Date(currentDate).getFullYear()))
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const normalized = new Date(currentDate)
|
||||
setCalendarDate(normalized)
|
||||
setSelectedDate(normalized)
|
||||
setViewMode('day')
|
||||
setYearPageStart(getYearPageStart(normalized.getFullYear()))
|
||||
}, [isOpen, currentDate])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -114,25 +120,78 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
onMonthChange?.(nextDate)
|
||||
}
|
||||
|
||||
const openMonthView = () => setViewMode('month')
|
||||
const openYearView = () => {
|
||||
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||
setViewMode('year')
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (viewMode === 'day') {
|
||||
openMonthView()
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
openYearView()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev - 12)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev + 12)
|
||||
}
|
||||
|
||||
const navTitle = viewMode === 'day'
|
||||
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||
: viewMode === 'month'
|
||||
? `${calendarDate.getFullYear()}年`
|
||||
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||
|
||||
return (
|
||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
onClick={handlePrev}
|
||||
aria-label="上一月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||
<button
|
||||
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||
onClick={handleTitleClick}
|
||||
type="button"
|
||||
>
|
||||
{navTitle}
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={handleNext}
|
||||
aria-label="下一月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
@@ -154,36 +213,74 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(day => (
|
||||
<div key={day} className="weekday">{day}</div>
|
||||
{viewMode === 'day' && (
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(day => (
|
||||
<div key={day} className="weekday">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((day, index) => {
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
{showCount && <span className="day-count">{count}</span>}
|
||||
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<div className="month-grid">
|
||||
{['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`month-cell ${monthIndex === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), monthIndex, 1))
|
||||
setViewMode('day')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((day, index) => {
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
{showCount && <span className="day-count">{count}</span>}
|
||||
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
|
||||
{viewMode === 'year' && (
|
||||
<div className="year-grid">
|
||||
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`year-cell ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||
setViewMode('month')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{year}年
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1401,6 +1401,220 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 版本更新页面
|
||||
.updates-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.updates-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border-color));
|
||||
background:
|
||||
linear-gradient(110deg, color-mix(in srgb, var(--bg-primary) 98%, #ffffff 2%), color-mix(in srgb, var(--bg-primary) 97%, var(--primary) 3%));
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.updates-hero-main {
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
margin: 10px 0 6px;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.updates-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.updates-hero-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.updates-card {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-primary) 96%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
.updates-progress-card {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 95%, var(--primary) 4%);
|
||||
}
|
||||
|
||||
.updates-progress-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.updates-card-header {
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.update-channel-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.update-channel-card {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.update-channel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.update-channel-card .desc {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.updates-progress-track {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.updates-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 72%, #ffffff));
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent);
|
||||
animation: updatesShimmer 1.7s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.updates-ignore-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.updates-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.updates-hero-action {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes updatesShimmer {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 关于页面
|
||||
.about-tab {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
@@ -24,9 +24,9 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||
|
||||
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||
{ id: 'updates', label: '版本更新', icon: RefreshCw },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
|
||||
@@ -68,7 +68,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setDownloadProgress,
|
||||
showUpdateDialog,
|
||||
setShowUpdateDialog,
|
||||
setUpdateError
|
||||
} = useAppStore()
|
||||
|
||||
const resetChatStore = useChatStore((state) => state.reset)
|
||||
@@ -141,6 +140,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
||||
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
||||
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||
@@ -339,6 +339,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||
const savedUpdateChannel = await configService.getUpdateChannel()
|
||||
|
||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||
@@ -387,6 +388,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setMessagePushEnabled(savedMessagePushEnabled)
|
||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||
setQuoteLayout(savedQuoteLayout)
|
||||
if (savedUpdateChannel) {
|
||||
setUpdateChannel(savedUpdateChannel)
|
||||
} else {
|
||||
const currentVersion = await window.electronAPI.app.getVersion()
|
||||
if (/-preview\.\d+\.\d+$/i.test(currentVersion)) {
|
||||
setUpdateChannel('preview')
|
||||
} else if (/-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
|
||||
setUpdateChannel('dev')
|
||||
} else {
|
||||
setUpdateChannel('stable')
|
||||
}
|
||||
}
|
||||
|
||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||
setWordCloudExcludeWords(savedExcludeWords)
|
||||
@@ -512,7 +525,22 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateChannelChange = async (channel: configService.UpdateChannel) => {
|
||||
if (channel === updateChannel) return
|
||||
|
||||
try {
|
||||
setUpdateChannel(channel)
|
||||
await configService.setUpdateChannel(channel)
|
||||
await configService.setIgnoredUpdateVersion('')
|
||||
setUpdateInfo(null)
|
||||
setShowUpdateDialog(false)
|
||||
const channelLabel = channel === 'stable' ? '稳定版' : channel === 'preview' ? '预览版' : '开发版'
|
||||
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
|
||||
await handleCheckUpdate()
|
||||
} catch (e: any) {
|
||||
showMessage(`切换更新渠道失败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
const showMessage = (text: string, success: boolean) => {
|
||||
setMessage({ text, success })
|
||||
@@ -1474,6 +1502,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
const renderDatabaseTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>连接测试</label>
|
||||
<span className="form-hint">检测当前数据库配置是否可用</span>
|
||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
||||
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>解密密钥</label>
|
||||
<span className="form-hint">64位十六进制密钥</span>
|
||||
@@ -2400,35 +2438,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<img src="./logo.png" alt="WeFlow" />
|
||||
</div>
|
||||
<h2 className="about-name">WeFlow</h2>
|
||||
<p className="about-slogan">WeFlow</p>
|
||||
<p className="about-version">v{appVersion || '...'}</p>
|
||||
|
||||
<div className="about-update">
|
||||
{updateInfo?.hasUpdate ? (
|
||||
<>
|
||||
<p className="update-hint">新版 v{updateInfo.version} 可用</p>
|
||||
{isDownloading ? (
|
||||
<div className="update-progress">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-inner" style={{ width: `${(downloadProgress?.percent || 0)}%` }} />
|
||||
</div>
|
||||
<span>{(downloadProgress?.percent || 0).toFixed(0)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
|
||||
<Download size={16} /> 立即更新
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-footer">
|
||||
@@ -2440,7 +2450,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span>·</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议</a>
|
||||
</div>
|
||||
<p className="copyright">© 2025 WeFlow. All rights reserved.</p>
|
||||
<p className="copyright">© 2026 WeFlow. All rights reserved.</p>
|
||||
|
||||
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: '13px', opacity: 0.7 }}>匿名数据收集</span>
|
||||
@@ -2463,6 +2473,82 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderUpdatesTab = () => {
|
||||
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
|
||||
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
|
||||
{ id: 'stable', title: '稳定版', desc: '正式发布的版本,适合日常使用' },
|
||||
{ id: 'preview', title: '预览版', desc: '正式发布前的预览体验版本' },
|
||||
{ id: 'dev', title: '开发版', desc: '即刻体验我们的屎山代码' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="tab-content updates-tab">
|
||||
<div className="updates-hero">
|
||||
<div className="updates-hero-main">
|
||||
<span className="updates-chip">当前版本</span>
|
||||
<h2>{appVersion || '...'}</h2>
|
||||
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}</p>
|
||||
</div>
|
||||
<div className="updates-hero-action">
|
||||
{updateInfo?.hasUpdate ? (
|
||||
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
|
||||
<Download size={16} /> 立即更新
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isDownloading || updateInfo?.hasUpdate) && (
|
||||
<div className="updates-progress-card">
|
||||
<div className="updates-progress-header">
|
||||
<h3>{isDownloading ? `正在下载 v${updateInfo?.version || ''}` : `新版本 v${updateInfo?.version} 已就绪`}</h3>
|
||||
{isDownloading ? <strong>{downloadPercent.toFixed(0)}%</strong> : <span>可立即安装</span>}
|
||||
</div>
|
||||
<div className="updates-progress-track">
|
||||
<div className="updates-progress-fill" style={{ width: `${isDownloading ? downloadPercent : 100}%` }} />
|
||||
</div>
|
||||
{updateInfo?.hasUpdate && !isDownloading && (
|
||||
<button className="btn btn-secondary updates-ignore-btn" onClick={handleIgnoreUpdate}>
|
||||
暂不提醒此版本
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="updates-card">
|
||||
<div className="updates-card-header">
|
||||
<h3>更新渠道</h3>
|
||||
<span>切换渠道后会自动重新检查</span>
|
||||
</div>
|
||||
<div className="update-channel-grid">
|
||||
{channelCards.map((channel) => {
|
||||
const active = updateChannel === channel.id
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
className={`update-channel-card ${active ? 'active' : ''}`}
|
||||
onClick={() => void handleUpdateChannelChange(channel.id)}
|
||||
disabled={active}
|
||||
>
|
||||
<div className="update-channel-title-row">
|
||||
<span className="title">{channel.title}</span>
|
||||
{active && <Check size={16} />}
|
||||
</div>
|
||||
<span className="desc">{channel.desc}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
|
||||
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
|
||||
@@ -2510,9 +2596,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<h1>设置</h1>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
||||
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
|
||||
<X size={18} />
|
||||
@@ -2542,6 +2625,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'api' && renderApiTab()}
|
||||
{activeTab === 'updates' && renderUpdatesTab()}
|
||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||
{activeTab === 'security' && renderSecurityTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
|
||||
@@ -58,6 +58,7 @@ export const CONFIG_KEYS = {
|
||||
|
||||
// 更新
|
||||
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
|
||||
UPDATE_CHANNEL: 'updateChannel',
|
||||
|
||||
// 通知
|
||||
NOTIFICATION_ENABLED: 'notificationEnabled',
|
||||
@@ -96,6 +97,7 @@ export interface ExportDefaultMediaConfig {
|
||||
|
||||
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
export type QuoteLayout = 'quote-top' | 'quote-bottom'
|
||||
export type UpdateChannel = 'stable' | 'preview' | 'dev'
|
||||
|
||||
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||
images: true,
|
||||
@@ -1381,6 +1383,18 @@ export async function setIgnoredUpdateVersion(version: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
|
||||
}
|
||||
|
||||
// 获取更新渠道(空值/auto 视为未显式设置,交由安装包类型决定默认渠道)
|
||||
export async function getUpdateChannel(): Promise<UpdateChannel | null> {
|
||||
const value = await config.get(CONFIG_KEYS.UPDATE_CHANNEL)
|
||||
if (value === 'stable' || value === 'preview' || value === 'dev') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置更新渠道
|
||||
export async function setUpdateChannel(channel: UpdateChannel): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.UPDATE_CHANNEL, channel)
|
||||
}
|
||||
|
||||
// 获取通知开关
|
||||
export async function getNotificationEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
|
||||
|
||||
Reference in New Issue
Block a user