feat(remote): 添加 CORS 中间件支持文件跨域访问
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 贡献指南
|
# 贡献指南
|
||||||
|
|
||||||
感谢你对 XCNote 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于:
|
感谢你对 XCDesktop 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于:
|
||||||
|
|
||||||
- 🐛 报告 Bug
|
- 🐛 报告 Bug
|
||||||
- 💡 提出新功能建议
|
- 💡 提出新功能建议
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
### 克隆项目
|
### 克隆项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-repo/XCNote.git
|
git clone https://github.com/your-repo/XCDesktop.git
|
||||||
cd XCNote
|
cd XCDesktop
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安装依赖
|
### 安装依赖
|
||||||
@@ -162,7 +162,7 @@ fix(editor): 修复保存内容丢失问题
|
|||||||
## 项目结构概览
|
## 项目结构概览
|
||||||
|
|
||||||
```
|
```
|
||||||
XCNote/
|
XCDesktop/
|
||||||
├── src/ # 前端源码
|
├── src/ # 前端源码
|
||||||
│ ├── components/ # UI 组件
|
│ ├── components/ # UI 组件
|
||||||
│ ├── contexts/ # React Context
|
│ ├── contexts/ # React Context
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const config = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
get tempRoot(): string {
|
get tempRoot(): string {
|
||||||
return path.join(os.tmpdir(), 'xcnote_uploads')
|
return path.join(os.tmpdir(), 'xcdesktop_uploads')
|
||||||
},
|
},
|
||||||
|
|
||||||
get serverPort(): number {
|
get serverPort(): number {
|
||||||
|
|||||||
@@ -32,6 +32,110 @@ import { logger } from '../../utils/logger.js'
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/drives',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const drives: FileItemDTO[] = []
|
||||||
|
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||||
|
|
||||||
|
for (const letter of letters) {
|
||||||
|
const drivePath = `${letter}:\\`
|
||||||
|
try {
|
||||||
|
await fs.access(drivePath)
|
||||||
|
drives.push({
|
||||||
|
name: `${letter}:`,
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date().toISOString(),
|
||||||
|
path: drivePath,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 驱动器不存在,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, { items: drives })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/system',
|
||||||
|
validateQuery(listFilesQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const systemPath = req.query.path as string
|
||||||
|
if (!systemPath) {
|
||||||
|
throw new BadRequestError('路径不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.resolve(systemPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('路径不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fs.stat(fullPath)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new NotADirectoryError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fs.readdir(fullPath)
|
||||||
|
const items = await Promise.all(
|
||||||
|
files.map(async (name): Promise<FileItemDTO | null> => {
|
||||||
|
const filePath = path.join(fullPath, name)
|
||||||
|
try {
|
||||||
|
const fileStats = await fs.stat(filePath)
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: fileStats.isDirectory() ? 'dir' : 'file',
|
||||||
|
size: fileStats.size,
|
||||||
|
modified: fileStats.mtime.toISOString(),
|
||||||
|
path: filePath,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.'))
|
||||||
|
visibleItems.sort((a, b) => {
|
||||||
|
if (a.type === b.type) return a.name.localeCompare(b.name)
|
||||||
|
return a.type === 'dir' ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, { items: visibleItems })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/system/content',
|
||||||
|
validateQuery(contentQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const systemPath = req.query.path as string
|
||||||
|
if (!systemPath) {
|
||||||
|
throw new BadRequestError('路径不能为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.resolve(systemPath)
|
||||||
|
const stats = await fs.stat(fullPath).catch(() => {
|
||||||
|
throw new NotFoundError('文件不存在')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stats.isFile()) throw new BadRequestError('不是文件')
|
||||||
|
|
||||||
|
const content = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
successResponse(res, {
|
||||||
|
content,
|
||||||
|
metadata: {
|
||||||
|
size: stats.size,
|
||||||
|
modified: stats.mtime.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
validateQuery(listFilesQuerySchema),
|
validateQuery(listFilesQuerySchema),
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
const filePath = getMonthFilePath(year, month)
|
const filePath = getMonthFilePath(year, month)
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, 'utf-8')
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
return JSON.parse(content)
|
const data = JSON.parse(content)
|
||||||
|
data.activeDays = Object.values(data.days).filter(d => d.totalDuration > 0).length
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createEmptyMonthData(year, month)
|
return createEmptyMonthData(year, month)
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,9 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
const filePath = getYearFilePath(year)
|
const filePath = getYearFilePath(year)
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, 'utf-8')
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
return JSON.parse(content)
|
const data = JSON.parse(content)
|
||||||
|
data.totalActiveDays = Object.values(data.months).filter(m => m.totalDuration > 0).length
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return createEmptyYearData(year)
|
return createEmptyYearData(year)
|
||||||
}
|
}
|
||||||
@@ -226,7 +230,7 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
if (existingSessionIndex >= 0) {
|
if (existingSessionIndex >= 0) {
|
||||||
const oldDuration = dayData.sessions[existingSessionIndex].duration
|
const oldDuration = dayData.sessions[existingSessionIndex].duration
|
||||||
dayData.sessions[existingSessionIndex] = realtimeSession
|
dayData.sessions[existingSessionIndex] = realtimeSession
|
||||||
dayData.totalDuration += currentSessionDuration - oldDuration
|
dayData.totalDuration = dayData.totalDuration - oldDuration + currentSessionDuration
|
||||||
} else {
|
} else {
|
||||||
dayData.sessions.push(realtimeSession)
|
dayData.sessions.push(realtimeSession)
|
||||||
dayData.totalDuration += currentSessionDuration
|
dayData.totalDuration += currentSessionDuration
|
||||||
@@ -269,8 +273,10 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
monthData.days[dayStr].totalDuration += duration
|
monthData.days[dayStr].totalDuration += duration
|
||||||
monthData.days[dayStr].sessions += 1
|
monthData.days[dayStr].sessions += 1
|
||||||
monthData.monthlyTotal += duration
|
monthData.monthlyTotal += duration
|
||||||
monthData.activeDays = Object.keys(monthData.days).length
|
monthData.activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length
|
||||||
monthData.averageDaily = Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
monthData.averageDaily = monthData.activeDays > 0
|
||||||
|
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
||||||
|
: 0
|
||||||
monthData.lastUpdated = new Date().toISOString()
|
monthData.lastUpdated = new Date().toISOString()
|
||||||
|
|
||||||
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), 'utf-8')
|
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), 'utf-8')
|
||||||
@@ -289,10 +295,15 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
|
|
||||||
yearData.months[monthStr].totalDuration += duration
|
yearData.months[monthStr].totalDuration += duration
|
||||||
yearData.yearlyTotal += duration
|
yearData.yearlyTotal += duration
|
||||||
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
|
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => {
|
||||||
|
const hasActiveDays = m.totalDuration > 0 ? 1 : 0
|
||||||
|
return sum + hasActiveDays
|
||||||
|
}, 0)
|
||||||
|
|
||||||
const monthCount = Object.keys(yearData.months).length
|
const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length
|
||||||
yearData.averageMonthly = Math.floor(yearData.yearlyTotal / monthCount)
|
yearData.averageMonthly = activeMonthCount > 0
|
||||||
|
? Math.floor(yearData.yearlyTotal / activeMonthCount)
|
||||||
|
: 0
|
||||||
yearData.averageDaily = yearData.totalActiveDays > 0
|
yearData.averageDaily = yearData.totalActiveDays > 0
|
||||||
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
||||||
: 0
|
: 0
|
||||||
@@ -315,7 +326,7 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
const oldDayDuration = monthData.days[dayStr].totalDuration
|
const oldDayDuration = monthData.days[dayStr].totalDuration
|
||||||
monthData.days[dayStr].totalDuration = todayDuration
|
monthData.days[dayStr].totalDuration = todayDuration
|
||||||
monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration
|
monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration
|
||||||
monthData.activeDays = Object.keys(monthData.days).length
|
monthData.activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length
|
||||||
monthData.averageDaily = monthData.activeDays > 0
|
monthData.averageDaily = monthData.activeDays > 0
|
||||||
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
||||||
: 0
|
: 0
|
||||||
@@ -345,10 +356,15 @@ class SessionPersistenceService implements SessionPersistence {
|
|||||||
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
|
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
|
||||||
yearData.months[monthStr].activeDays = monthData.activeDays
|
yearData.months[monthStr].activeDays = monthData.activeDays
|
||||||
yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal
|
yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal
|
||||||
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
|
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => {
|
||||||
|
const hasActiveDays = m.totalDuration > 0 ? 1 : 0
|
||||||
|
return sum + hasActiveDays
|
||||||
|
}, 0)
|
||||||
|
|
||||||
const monthCount = Object.keys(yearData.months).length
|
const activeMonthCount = Object.values(yearData.months).filter(m => m.totalDuration > 0).length
|
||||||
yearData.averageMonthly = monthCount > 0 ? Math.floor(yearData.yearlyTotal / monthCount) : 0
|
yearData.averageMonthly = activeMonthCount > 0
|
||||||
|
? Math.floor(yearData.yearlyTotal / activeMonthCount)
|
||||||
|
: 0
|
||||||
yearData.averageDaily = yearData.totalActiveDays > 0
|
yearData.averageDaily = yearData.totalActiveDays > 0
|
||||||
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
||||||
: 0
|
: 0
|
||||||
|
|||||||
@@ -366,23 +366,47 @@ class TimeTrackerService {
|
|||||||
if (targetMonth) {
|
if (targetMonth) {
|
||||||
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
|
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
|
||||||
totalDuration = monthData.monthlyTotal
|
totalDuration = monthData.monthlyTotal
|
||||||
activeDays = monthData.activeDays
|
activeDays = Object.values(monthData.days).filter(d => d.totalDuration > 0).length
|
||||||
|
|
||||||
for (const [day, summary] of Object.entries(monthData.days)) {
|
for (const [day, summary] of Object.entries(monthData.days)) {
|
||||||
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||||
longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, '0')}-${day}`, duration: summary.totalDuration }
|
longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, '0')}-${day}`, duration: summary.totalDuration }
|
||||||
}
|
}
|
||||||
|
for (const tab of summary.topTabs || []) {
|
||||||
|
tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayData = await this.persistence.getDayData(targetYear, targetMonth, 1)
|
||||||
|
for (const session of dayData.sessions) {
|
||||||
|
for (const record of session.tabRecords) {
|
||||||
|
const key = record.filePath || record.fileName
|
||||||
|
tabDurations[key] = (tabDurations[key] || 0) + record.duration
|
||||||
|
tabTypeDurations[record.tabType] = (tabTypeDurations[record.tabType] || 0) + record.duration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const yearData = await this.persistence.getYearData(targetYear)
|
const yearData = await this.persistence.getYearData(targetYear)
|
||||||
totalDuration = yearData.yearlyTotal
|
totalDuration = yearData.yearlyTotal
|
||||||
activeDays = yearData.totalActiveDays
|
activeDays = Object.values(yearData.months).reduce((sum, m) => {
|
||||||
|
return sum + Object.entries(m).filter(([_, d]) => (d as { totalDuration: number }).totalDuration > 0).length
|
||||||
|
}, 0)
|
||||||
|
|
||||||
for (const [month, summary] of Object.entries(yearData.months)) {
|
for (const [month, summary] of Object.entries(yearData.months)) {
|
||||||
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||||
longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration }
|
longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
const monthStr = m.toString().padStart(2, '0')
|
||||||
|
const monthData = await this.persistence.getMonthData(targetYear, m)
|
||||||
|
for (const dayData of Object.values(monthData.days)) {
|
||||||
|
for (const tab of dayData.topTabs || []) {
|
||||||
|
tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ var electronState = new ElectronState();
|
|||||||
log2.initialize();
|
log2.initialize();
|
||||||
var __filename = fileURLToPath(import.meta.url);
|
var __filename = fileURLToPath(import.meta.url);
|
||||||
var __dirname = path2.dirname(__filename);
|
var __dirname = path2.dirname(__filename);
|
||||||
process.env.NOTEBOOK_ROOT = path2.join(app.getPath("documents"), "XCNote");
|
process.env.NOTEBOOK_ROOT = path2.join(app.getPath("documents"), "XCDesktop");
|
||||||
if (!fs2.existsSync(process.env.NOTEBOOK_ROOT)) {
|
if (!fs2.existsSync(process.env.NOTEBOOK_ROOT)) {
|
||||||
try {
|
try {
|
||||||
fs2.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
fs2.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@ log.initialize();
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
process.env.NOTEBOOK_ROOT = path.join(app.getPath('documents'), 'XCNote');
|
process.env.NOTEBOOK_ROOT = path.join(app.getPath('documents'), 'XCDesktop');
|
||||||
|
|
||||||
if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) {
|
if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>XCNote</title>
|
<title>XCDesktop</title>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
if (import.meta.hot?.on) {
|
if (import.meta.hot?.on) {
|
||||||
import.meta.hot.on('vite:error', (error) => {
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "xcnote",
|
"name": "xcdesktop",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "一个功能强大的本地 Markdown 笔记管理工具,支持时间追踪、任务管理、AI 集成等高级功能",
|
"description": "一站式 AI 工作台 - 集成笔记管理、时间追踪、任务管理、AI 辅助等多种功能",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"markdown",
|
"markdown",
|
||||||
"note",
|
"note",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/your-username/xcnote.git"
|
"url": "https://github.com/your-username/xcdesktop.git"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
@@ -111,8 +111,8 @@
|
|||||||
"wait-on": "^9.0.3"
|
"wait-on": "^9.0.3"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.xcnote.app",
|
"appId": "com.xcdesktop.app",
|
||||||
"productName": "XCNote",
|
"productName": "XCDesktop",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ serverAddr = "146.56.248.142"
|
|||||||
serverPort = 7000
|
serverPort = 7000
|
||||||
auth.token = "wzw20040525"
|
auth.token = "wzw20040525"
|
||||||
|
|
||||||
log.to = "C:\\Users\\xuanchi\\Desktop\\remote\\logs\\frpc.log"
|
log.to = "D:\\Xuanchi\\高斯泼溅\\XCDesktop\\remote\\logs\\frpc.log"
|
||||||
log.level = "info"
|
log.level = "info"
|
||||||
log.maxDays = 7
|
log.maxDays = 7
|
||||||
|
|
||||||
|
|||||||
27
remote/package-lock.json
generated
27
remote/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"config": "^3.3.12",
|
"config": "^3.3.12",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"h264-live-player": "^1.3.1",
|
"h264-live-player": "^1.3.1",
|
||||||
@@ -771,6 +772,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1985,6 +2003,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"config": "^3.3.12",
|
"config": "^3.3.12",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"h264-live-player": "^1.3.1",
|
"h264-live-player": "^1.3.1",
|
||||||
|
|||||||
@@ -223,6 +223,17 @@ class App {
|
|||||||
const authMiddleware = require('../middlewares/auth');
|
const authMiddleware = require('../middlewares/auth');
|
||||||
const routes = require('../routes');
|
const routes = require('../routes');
|
||||||
|
|
||||||
|
// 简单的 CORS 中间件
|
||||||
|
httpServer.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(200);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
httpServer.use(cookieParser());
|
httpServer.use(cookieParser());
|
||||||
httpServer.use(express.json());
|
httpServer.use(express.json());
|
||||||
httpServer.use(express.urlencoded({ extended: true }));
|
httpServer.use(express.urlencoded({ extended: true }));
|
||||||
@@ -241,6 +252,11 @@ class App {
|
|||||||
});
|
});
|
||||||
|
|
||||||
httpServer.use(async (req, res, next) => {
|
httpServer.use(async (req, res, next) => {
|
||||||
|
// 放行 CORS 预检请求
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
if (!authService.hasPassword()) {
|
if (!authService.hasPassword()) {
|
||||||
res.locals.authenticated = true;
|
res.locals.authenticated = true;
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type TabType = 'markdown' | 'todo' | 'settings' | 'search' | 'recycle-bin' | 'weread' | 'time-tracking' | 'pydemos' | 'remote' | 'remote-desktop' | 'remote-git' | 'other'
|
export type TabType = 'markdown' | 'todo' | 'settings' | 'search' | 'recycle-bin' | 'weread' | 'time-tracking' | 'pydemos' | 'remote' | 'remote-desktop' | 'remote-git' | 'file-transfer' | 'other'
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const KNOWN_MODULE_IDS = [
|
|||||||
export function getTabTypeFromPath(filePath: string | null): TabType {
|
export function getTabTypeFromPath(filePath: string | null): TabType {
|
||||||
if (!filePath) return 'other'
|
if (!filePath) return 'other'
|
||||||
|
|
||||||
|
if (filePath.startsWith('file-transfer-panel')) {
|
||||||
|
return 'file-transfer'
|
||||||
|
}
|
||||||
|
|
||||||
if (filePath.startsWith('remote-git://')) {
|
if (filePath.startsWith('remote-git://')) {
|
||||||
return 'remote-git'
|
return 'remote-git'
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,12 @@ export function getTabTypeFromPath(filePath: string | null): TabType {
|
|||||||
export function getFileNameFromPath(filePath: string | null): string {
|
export function getFileNameFromPath(filePath: string | null): string {
|
||||||
if (!filePath) return '未知'
|
if (!filePath) return '未知'
|
||||||
|
|
||||||
|
if (filePath.startsWith('file-transfer-panel')) {
|
||||||
|
const params = new URLSearchParams(filePath.split('?')[1] || '')
|
||||||
|
const deviceName = params.get('device') || ''
|
||||||
|
return deviceName ? `文件传输 - ${deviceName}` : '文件传输'
|
||||||
|
}
|
||||||
|
|
||||||
for (const moduleId of KNOWN_MODULE_IDS) {
|
for (const moduleId of KNOWN_MODULE_IDS) {
|
||||||
if (filePath === `${moduleId}-tab` || filePath === moduleId) {
|
if (filePath === `${moduleId}-tab` || filePath === moduleId) {
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ const SidebarContent = forwardRef<HTMLDivElement, SidebarProps>(({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 w-1 hover:bg-gray-300 dark:hover:bg-gray-600 cursor-col-resize transition-colors z-10"
|
className="absolute right-0 top-0 bottom-0 hover:bg-gray-300 dark:hover:bg-gray-600 cursor-col-resize transition-colors z-10"
|
||||||
|
style={{ right: -2, width: 4 }}
|
||||||
onMouseDown={onResizeStart}
|
onMouseDown={onResizeStart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { Folder, FileText, ChevronLeft, RefreshCw } from 'lucide-react'
|
import { Folder, FileText, ChevronLeft, RefreshCw, HardDrive } from 'lucide-react'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import type { FileItem } from '@/lib/api'
|
import type { FileItem } from '@/lib/api'
|
||||||
import { fetchSystemFiles } from '../../api'
|
import { fetchSystemFiles, fetchSystemDrives } from '../../api'
|
||||||
|
|
||||||
interface LocalFilePanelProps {
|
interface LocalFilePanelProps {
|
||||||
selectedFile: FileItem | null
|
selectedFile: FileItem | null
|
||||||
@@ -21,6 +21,7 @@ export const LocalFilePanel: React.FC<LocalFilePanelProps> = ({
|
|||||||
const [files, setFiles] = useState<FileItem[]>([])
|
const [files, setFiles] = useState<FileItem[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [pathHistory, setPathHistory] = useState<string[]>([''])
|
const [pathHistory, setPathHistory] = useState<string[]>([''])
|
||||||
|
const [isAtDrives, setIsAtDrives] = useState(true)
|
||||||
|
|
||||||
const loadFiles = useCallback(async (systemPath: string) => {
|
const loadFiles = useCallback(async (systemPath: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -34,9 +35,25 @@ export const LocalFilePanel: React.FC<LocalFilePanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadDrives = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const items = await fetchSystemDrives()
|
||||||
|
setFiles(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load drives:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isAtDrives) {
|
||||||
|
loadDrives()
|
||||||
|
} else {
|
||||||
loadFiles(currentPath)
|
loadFiles(currentPath)
|
||||||
}, [currentPath, loadFiles])
|
}
|
||||||
|
}, [currentPath, isAtDrives, loadFiles, loadDrives])
|
||||||
|
|
||||||
const handleGoBack = () => {
|
const handleGoBack = () => {
|
||||||
if (pathHistory.length > 1) {
|
if (pathHistory.length > 1) {
|
||||||
@@ -45,23 +62,30 @@ export const LocalFilePanel: React.FC<LocalFilePanelProps> = ({
|
|||||||
const prevPath = newHistory[newHistory.length - 1]
|
const prevPath = newHistory[newHistory.length - 1]
|
||||||
setPathHistory(newHistory)
|
setPathHistory(newHistory)
|
||||||
setCurrentPath(prevPath)
|
setCurrentPath(prevPath)
|
||||||
|
if (prevPath === '') {
|
||||||
|
setIsAtDrives(true)
|
||||||
|
}
|
||||||
onSelect(null)
|
onSelect(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGoInto = (file: FileItem) => {
|
const handleGoInto = (file: FileItem) => {
|
||||||
|
setIsAtDrives(false)
|
||||||
setPathHistory([...pathHistory, file.path])
|
setPathHistory([...pathHistory, file.path])
|
||||||
setCurrentPath(file.path)
|
setCurrentPath(file.path)
|
||||||
onSelect(null)
|
onSelect(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
|
if (isAtDrives) {
|
||||||
|
loadDrives()
|
||||||
|
} else {
|
||||||
loadFiles(currentPath)
|
loadFiles(currentPath)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getDisplayPath = () => {
|
const getDisplayPath = () => {
|
||||||
if (!currentPath) return '本地文件'
|
if (isAtDrives) return '本地磁盘'
|
||||||
if (currentPath === '' || currentPath === '/') return '根目录'
|
|
||||||
const parts = currentPath.split(/[/\\]/)
|
const parts = currentPath.split(/[/\\]/)
|
||||||
return parts[parts.length - 1] || currentPath
|
return parts[parts.length - 1] || currentPath
|
||||||
}
|
}
|
||||||
@@ -119,8 +143,12 @@ export const LocalFilePanel: React.FC<LocalFilePanelProps> = ({
|
|||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-200'
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-200'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{file.type === 'dir' ? (
|
{file.type === 'dir' || file.path.match(/^[A-Z]:\\?$/i) ? (
|
||||||
|
file.path.match(/^[A-Z]:\\?$/i) ? (
|
||||||
|
<HardDrive size={16} className="text-blue-500 dark:text-blue-400 shrink-0" />
|
||||||
|
) : (
|
||||||
<Folder size={16} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
<Folder size={16} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<FileText size={16} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
<FileText size={16} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { ChevronLeft, ChevronRight, Clock, FileText, CheckSquare, Settings, Search, Trash2, BookOpen, Monitor, Code, GitBranch } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Clock, FileText, CheckSquare, Settings, Search, Trash2, BookOpen, Monitor, Code, GitBranch } from 'lucide-react'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, BarChart, Bar } from 'recharts'
|
||||||
import { getDayTimeData, getMonthTimeData, getYearTimeData } from './api'
|
import { getDayTimeData, getWeekTimeData, getMonthTimeData, getYearTimeData } from './api'
|
||||||
|
import type { WeekTimeData } from './api'
|
||||||
import { WEEK_DAYS } from '@shared/constants'
|
import { WEEK_DAYS } from '@shared/constants'
|
||||||
import type { DayTimeData, MonthTimeData, YearTimeData, TabType, TabSummary, DaySummary } from '@shared/types'
|
import type { DayTimeData, MonthTimeData, YearTimeData, TabType, TabSummary, DaySummary } from '@shared/types/time'
|
||||||
import { formatDurationShort, getWeekStart } from '@shared/utils/date'
|
import { formatDurationShort, getWeekStart } from '@shared/utils/date'
|
||||||
|
import { ViewMode } from './types'
|
||||||
type ViewMode = 'day' | 'week' | 'month' | 'year'
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
return formatDurationShort(seconds * 1000)
|
return formatDurationShort(seconds * 1000)
|
||||||
@@ -65,9 +65,9 @@ const getTabColor = (tabType: TabType): string => {
|
|||||||
case 'remote':
|
case 'remote':
|
||||||
return 'text-indigo-500'
|
return 'text-indigo-500'
|
||||||
case 'remote-desktop':
|
case 'remote-desktop':
|
||||||
return 'text-indigo-500'
|
return 'text-blue-600'
|
||||||
case 'remote-git':
|
case 'remote-git':
|
||||||
return 'text-orange-500'
|
return 'text-teal-500'
|
||||||
case 'pydemos':
|
case 'pydemos':
|
||||||
return 'text-yellow-500'
|
return 'text-yellow-500'
|
||||||
default:
|
default:
|
||||||
@@ -156,20 +156,44 @@ const getHourlyUsageData = (sessions: Array<{ startTime: string; endTime?: strin
|
|||||||
const endTime = session.endTime ? new Date(session.endTime) : new Date()
|
const endTime = session.endTime ? new Date(session.endTime) : new Date()
|
||||||
const startHour = startTime.getHours()
|
const startHour = startTime.getHours()
|
||||||
const endHour = endTime.getHours()
|
const endHour = endTime.getHours()
|
||||||
|
const startMinute = startTime.getMinutes()
|
||||||
|
const startSecond = startTime.getSeconds()
|
||||||
|
const endMinute = endTime.getMinutes()
|
||||||
|
const endSecond = endTime.getSeconds()
|
||||||
|
|
||||||
|
if (startHour === endHour) {
|
||||||
|
const duration = session.duration
|
||||||
|
hourlyMap[startHour] += duration
|
||||||
|
} else if (startHour < endHour) {
|
||||||
for (let h = startHour; h <= endHour && h < 24; h++) {
|
for (let h = startHour; h <= endHour && h < 24; h++) {
|
||||||
if (h === startHour && h === endHour) {
|
if (h === startHour) {
|
||||||
const duration = Math.min(session.duration, 3600)
|
const remainingMinutes = 60 - startMinute
|
||||||
hourlyMap[h] += duration
|
const remainingSeconds = remainingMinutes * 60 - startSecond
|
||||||
} else if (h === startHour) {
|
hourlyMap[h] += remainingSeconds
|
||||||
const minutes = 60 - startTime.getMinutes()
|
|
||||||
hourlyMap[h] += minutes * 60
|
|
||||||
} else if (h === endHour) {
|
} else if (h === endHour) {
|
||||||
hourlyMap[h] += endTime.getMinutes() * 60 + endTime.getSeconds()
|
hourlyMap[h] += endMinute * 60 + endSecond
|
||||||
} else {
|
} else {
|
||||||
hourlyMap[h] += 3600
|
hourlyMap[h] += 3600
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
for (let h = startHour; h < 24; h++) {
|
||||||
|
if (h === startHour) {
|
||||||
|
const remainingMinutes = 60 - startMinute
|
||||||
|
const remainingSeconds = remainingMinutes * 60 - startSecond
|
||||||
|
hourlyMap[h] += remainingSeconds
|
||||||
|
} else {
|
||||||
|
hourlyMap[h] += 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let h = 0; h <= endHour; h++) {
|
||||||
|
if (h === endHour) {
|
||||||
|
hourlyMap[h] += endMinute * 60 + endSecond
|
||||||
|
} else {
|
||||||
|
hourlyMap[h] += 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,6 +209,7 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
const [viewMode, setViewMode] = useState<ViewMode>('day')
|
const [viewMode, setViewMode] = useState<ViewMode>('day')
|
||||||
const [currentDate, setCurrentDate] = useState(new Date())
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
const [dayData, setDayData] = useState<DayTimeData | null>(null)
|
const [dayData, setDayData] = useState<DayTimeData | null>(null)
|
||||||
|
const [weekData, setWeekData] = useState<WeekTimeData | null>(null)
|
||||||
const [monthData, setMonthData] = useState<MonthTimeData | null>(null)
|
const [monthData, setMonthData] = useState<MonthTimeData | null>(null)
|
||||||
const [yearData, setYearData] = useState<YearTimeData | null>(null)
|
const [yearData, setYearData] = useState<YearTimeData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -200,6 +225,11 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
|
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
|
||||||
const data = await getDayTimeData(dateStr)
|
const data = await getDayTimeData(dateStr)
|
||||||
setDayData(data)
|
setDayData(data)
|
||||||
|
} else if (viewMode === 'week') {
|
||||||
|
const weekStart = getWeekStart(currentDate)
|
||||||
|
const startDate = `${weekStart.getFullYear()}-${(weekStart.getMonth() + 1).toString().padStart(2, '0')}-${weekStart.getDate().toString().padStart(2, '0')}`
|
||||||
|
const data = await getWeekTimeData(startDate)
|
||||||
|
setWeekData(data)
|
||||||
} else if (viewMode === 'month') {
|
} else if (viewMode === 'month') {
|
||||||
const data = await getMonthTimeData(`${year}-${month.toString().padStart(2, '0')}`)
|
const data = await getMonthTimeData(`${year}-${month.toString().padStart(2, '0')}`)
|
||||||
setMonthData(data)
|
setMonthData(data)
|
||||||
@@ -222,6 +252,8 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
const newDate = new Date(currentDate)
|
const newDate = new Date(currentDate)
|
||||||
if (viewMode === 'day') {
|
if (viewMode === 'day') {
|
||||||
newDate.setDate(newDate.getDate() - 1)
|
newDate.setDate(newDate.getDate() - 1)
|
||||||
|
} else if (viewMode === 'week') {
|
||||||
|
newDate.setDate(newDate.getDate() - 7)
|
||||||
} else if (viewMode === 'month') {
|
} else if (viewMode === 'month') {
|
||||||
newDate.setMonth(newDate.getMonth() - 1)
|
newDate.setMonth(newDate.getMonth() - 1)
|
||||||
} else if (viewMode === 'year') {
|
} else if (viewMode === 'year') {
|
||||||
@@ -234,6 +266,8 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
const newDate = new Date(currentDate)
|
const newDate = new Date(currentDate)
|
||||||
if (viewMode === 'day') {
|
if (viewMode === 'day') {
|
||||||
newDate.setDate(newDate.getDate() + 1)
|
newDate.setDate(newDate.getDate() + 1)
|
||||||
|
} else if (viewMode === 'week') {
|
||||||
|
newDate.setDate(newDate.getDate() + 7)
|
||||||
} else if (viewMode === 'month') {
|
} else if (viewMode === 'month') {
|
||||||
newDate.setMonth(newDate.getMonth() + 1)
|
newDate.setMonth(newDate.getMonth() + 1)
|
||||||
} else if (viewMode === 'year') {
|
} else if (viewMode === 'year') {
|
||||||
@@ -249,6 +283,19 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
|
|
||||||
if (viewMode === 'day') {
|
if (viewMode === 'day') {
|
||||||
return `${year}年${month}月${day}日`
|
return `${year}年${month}月${day}日`
|
||||||
|
} else if (viewMode === 'week') {
|
||||||
|
const weekStart = getWeekStart(currentDate)
|
||||||
|
const weekEnd = new Date(weekStart)
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||||
|
const startMonth = weekStart.getMonth() + 1
|
||||||
|
const startDay = weekStart.getDate()
|
||||||
|
const endMonth = weekEnd.getMonth() + 1
|
||||||
|
const endDay = weekEnd.getDate()
|
||||||
|
if (startMonth === endMonth) {
|
||||||
|
return `${startMonth}月${startDay}日 - ${endDay}日`
|
||||||
|
} else {
|
||||||
|
return `${startMonth}月${startDay}日 - ${endMonth}月${endDay}日`
|
||||||
|
}
|
||||||
} else if (viewMode === 'month') {
|
} else if (viewMode === 'month') {
|
||||||
return `${year}年${month}月`
|
return `${year}年${month}月`
|
||||||
} else if (viewMode === 'year') {
|
} else if (viewMode === 'year') {
|
||||||
@@ -377,6 +424,126 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderWeekView = () => {
|
||||||
|
if (!weekData) return null
|
||||||
|
|
||||||
|
const weekStart = getWeekStart(currentDate)
|
||||||
|
const chartData = weekData.days.map((dayData, index) => {
|
||||||
|
const date = new Date(weekStart)
|
||||||
|
date.setDate(date.getDate() + index)
|
||||||
|
return {
|
||||||
|
day: WEEK_DAYS[date.getDay()],
|
||||||
|
date: `${date.getMonth() + 1}月${date.getDate()}日`,
|
||||||
|
duration: dayData.totalDuration,
|
||||||
|
durationStr: formatDuration(dayData.totalDuration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxDuration = Math.max(...chartData.map(d => d.duration), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">本周概览</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
{formatDuration(weekData.totalDuration)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">总时长</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
{weekData.activeDays}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">活跃天数</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
{formatDuration(weekData.averageDaily)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">日均时长</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weekData.totalDuration > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每日使用时长</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorWeekBar" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
||||||
|
tickFormatter={(value) => formatDuration(value)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#f3f4f6'
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#9ca3af' }}
|
||||||
|
formatter={(value: number) => [formatDuration(value), '使用时长']}
|
||||||
|
labelFormatter={(_, payload) => payload?.[0]?.payload?.date || ''}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="duration"
|
||||||
|
fill="url(#colorWeekBar)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每日明细</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chartData.map((data, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 w-12">
|
||||||
|
{data.day}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{data.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{data.durationStr}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weekData.totalDuration === 0 && (
|
||||||
|
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
||||||
|
暂无使用记录
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderMonthView = () => {
|
const renderMonthView = () => {
|
||||||
if (!monthData) return null
|
if (!monthData) return null
|
||||||
|
|
||||||
@@ -514,7 +681,7 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(['day', 'month', 'year'] as ViewMode[]).map((mode) => (
|
{(['day', 'week', 'month', 'year'] as ViewMode[]).map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => setViewMode(mode)}
|
onClick={() => setViewMode(mode)}
|
||||||
@@ -523,7 +690,7 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{mode === 'day' ? '日' : mode === 'month' ? '月' : '年'}
|
{mode === 'day' ? '日' : mode === 'week' ? '周' : mode === 'month' ? '月' : '年'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -553,6 +720,7 @@ export const TimeTrackingPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-2xl mx-auto pb-8">
|
<div className="max-w-2xl mx-auto pb-8">
|
||||||
{viewMode === 'day' && renderDayView()}
|
{viewMode === 'day' && renderDayView()}
|
||||||
|
{viewMode === 'week' && renderWeekView()}
|
||||||
{viewMode === 'month' && renderMonthView()}
|
{viewMode === 'month' && renderMonthView()}
|
||||||
{viewMode === 'year' && renderYearView()}
|
{viewMode === 'year' && renderYearView()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { DayTimeData } from '@shared/types/time'
|
||||||
|
|
||||||
export type ViewMode = 'day' | 'week' | 'month' | 'year'
|
export type ViewMode = 'day' | 'week' | 'month' | 'year'
|
||||||
|
|
||||||
export interface HourlyData {
|
export interface HourlyData {
|
||||||
@@ -5,10 +7,18 @@ export interface HourlyData {
|
|||||||
duration: number
|
duration: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WeekTimeData {
|
||||||
|
days: DayTimeData[]
|
||||||
|
totalDuration: number
|
||||||
|
activeDays: number
|
||||||
|
averageDaily: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimeTrackingPageState {
|
export interface TimeTrackingPageState {
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
currentDate: Date
|
currentDate: Date
|
||||||
dayData: import('@shared/types').DayTimeData | null
|
dayData: import('@shared/types').DayTimeData | null
|
||||||
|
weekData: WeekTimeData | null
|
||||||
monthData: import('@shared/types').MonthTimeData | null
|
monthData: import('@shared/types').MonthTimeData | null
|
||||||
yearData: import('@shared/types').YearTimeData | null
|
yearData: import('@shared/types').YearTimeData | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ MINERU_TOKEN = "eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ.eyJqdGkiOiI0NDIwMDY1NyIsI
|
|||||||
# 阿里云 OSS 配置
|
# 阿里云 OSS 配置
|
||||||
OSS_ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
|
OSS_ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
|
||||||
OSS_ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
|
OSS_ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
|
||||||
OSS_BUCKET_NAME = "bucket-xcnote" # 测试用的 OSS 桶名称
|
OSS_BUCKET_NAME = "bucket-xcdesktop" # 测试用的 OSS 桶名称
|
||||||
OSS_ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"
|
OSS_ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"
|
||||||
|
|
||||||
# 本地文件配置
|
# 本地文件配置
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def main():
|
|||||||
# 配置OSS参数
|
# 配置OSS参数
|
||||||
ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
|
ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
|
||||||
ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
|
ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
|
||||||
BUCKET_NAME = "bucket-xcnote" # 请替换为实际的OSS桶名称
|
BUCKET_NAME = "bucket-xcdesktop" # 请替换为实际的OSS桶名称
|
||||||
|
|
||||||
# 生成OSS对象名称
|
# 生成OSS对象名称
|
||||||
import datetime
|
import datetime
|
||||||
|
|||||||
Reference in New Issue
Block a user