Add multi-theme system with random theme selection on app startup

This commit is contained in:
sangge 2025-06-07 22:58:23 +08:00
commit 7e8f95e73e
47 changed files with 7677 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# private key and sensitive files
keystore.properties
upload-keystore.properties
upload-keystore.jks
*.jks
*.pem
*.p12
*.pfx
.env*
*.key
# Tauri build artifacts and generated files
src-tauri/gen/
src-tauri/target/
src-tauri/.gradle/
*.apk
*.aab

48
CLAUDE.md Normal file
View File

@ -0,0 +1,48 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Frontend Development
- `pnpm dev` - Start the frontend development server (runs Vite on localhost:1420)
- `pnpm build` - Build the frontend for production (TypeScript compilation + Vite build)
- `pnpm preview` - Preview the production build
### Tauri Development
- `pnpm tauri dev` - Start Tauri development mode (builds Rust backend + starts frontend)
- `pnpm tauri build` - Build the complete Tauri application for production
### Build Scripts
- `./build_desktop.sh` - Build desktop application
- `./build_android.sh` - Build Android application
## Architecture Overview
This is a **Tauri application** combining a Rust backend with a React/TypeScript frontend for building cross-platform desktop and mobile apps.
### Backend (Rust)
- **Database**: SQLite with rusqlite for storing anniversary records
- **Location**: Database stored in app data directory (`anniversaries.db`)
- **Schema**: `anniversaries` table with id, title, start_date, created_at
- **Core Commands**: All database operations exposed as Tauri commands in `src-tauri/src/lib.rs`
- `get_anniversaries` - Fetch all anniversaries with calculated days
- `get_anniversary_by_id` - Fetch single anniversary by ID
- `add_anniversary` - Add new anniversary
- `delete_anniversary` - Delete anniversary by ID
### Frontend (React + TypeScript)
- **Router**: React Router DOM with three main routes:
- `/` - HomePage (list all anniversaries)
- `/detail/:id` - DetailPage (view/delete specific anniversary)
- `/add` - AddPage (create new anniversary)
- **Data Flow**: Frontend calls Tauri commands to interact with Rust backend
- **Styling**: Component-specific CSS files for each page
### Key Dependencies
- **Frontend**: React 18, React Router DOM, Tauri API
- **Backend**: rusqlite (SQLite), chrono (date handling), serde (serialization)
- **Build**: Vite, TypeScript, Tauri CLI
### Package Manager
This project uses **pnpm** as specified in `tauri.conf.json` build commands.

7
README.md Normal file
View File

@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

1
build_android.sh Executable file
View File

@ -0,0 +1 @@
pnpm tauri android build --apk -t aarch64

1
build_desktop.sh Executable file
View File

@ -0,0 +1 @@
pnpm tauri build --no-bundle

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "countdown",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "cargo tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}

1239
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5101
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "countdown"
version = "0.1.0"
description = "A Countdown app"
authors = ["sangge"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "countdown_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
rusqlite = { version = "0.33.0", features = ["bundled"] }
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4.39"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

174
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,174 @@
use chrono::{Local, NaiveDate};
use rusqlite::{Connection, Result};
use serde::Serialize;
use std::fs;
use tauri::Manager;
fn init_database(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
// 获取应用数据目录
let app_dir = app.path().app_data_dir()?;
// 确保目录存在
fs::create_dir_all(&app_dir)?;
// 构建数据库文件路径
let db_path = app_dir.join("anniversaries.db");
// 打开数据库连接
let conn = Connection::open(db_path)?;
// 创建表
conn.execute(
"CREATE TABLE IF NOT EXISTS anniversaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
start_date TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
Ok(())
}
#[derive(Serialize)]
struct Anniversary {
id: i32,
title: String,
start_date: String,
days: i64,
}
#[tauri::command]
async fn get_anniversaries(app_handle: tauri::AppHandle) -> Result<Vec<Anniversary>, String> {
// 返回主页数据
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let db_path = app_dir.join("anniversaries.db");
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, title, start_date FROM anniversaries ORDER BY created_at DESC")
.map_err(|e| e.to_string())?;
let today = Local::now().date_naive();
let anniversaries = stmt
.query_map([], |row| {
let id: i32 = row.get(0)?;
let title: String = row.get(1)?;
let start_date: String = row.get(2)?;
// 计算天数
let start = NaiveDate::parse_from_str(&start_date, "%Y-%m-%d")
.map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?;
let days = (today - start).num_days();
Ok(Anniversary {
id,
title,
start_date,
days,
})
})
.map_err(|e| e.to_string())?;
let result: Result<Vec<_>, _> = anniversaries.collect();
result.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_anniversary_by_id(
app_handle: tauri::AppHandle,
id: i32,
) -> Result<Anniversary, String> {
// 返回详情页数据
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let db_path = app_dir.join("anniversaries.db");
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT title, start_date FROM anniversaries WHERE id = ?")
.map_err(|e| e.to_string())?;
let today = Local::now().date_naive();
let result = stmt
.query_row([id], |row| {
let title: String = row.get(0)?;
let start_date: String = row.get(1)?;
// 计算天数
let start = NaiveDate::parse_from_str(&start_date, "%Y-%m-%d")
.map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?;
let days = (today - start).num_days();
Ok(Anniversary {
id,
title,
start_date,
days,
})
})
.map_err(|e| e.to_string())?;
Ok(result)
}
#[tauri::command]
async fn delete_anniversary(app_handle: tauri::AppHandle, id: i64) -> Result<(), String> {
// 详情页删除
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let db_path = app_dir.join("anniversaries.db");
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM anniversaries WHERE id = ?", [id])
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn add_anniversary(
app_handle: tauri::AppHandle,
title: String,
start_date: String,
) -> Result<(), String> {
// 主页添加
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let db_path = app_dir.join("anniversaries.db");
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO anniversaries (title, start_date) VALUES (?1, ?2)",
[&title, &start_date],
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
init_database(app).expect("failed to initialize database");
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_anniversaries,
get_anniversary_by_id,
add_anniversary,
delete_anniversary,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
countdown_lib::run()
}

36
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,36 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "countdown",
"version": "0.1.0",
"identifier": "com.countdown.app",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "countdown",
"width": 800,
"height": 600
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
}
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "appimage", "nsis", "msi", "dmg", "app"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

222
src/AddPage.css Normal file
View File

@ -0,0 +1,222 @@
/* 基础样式 */
.container {
max-width: 600px;
margin: 0 auto;
padding: 24px;
background: var(--bg-secondary);
min-height: 100vh;
}
.nav-bar {
display: flex;
align-items: center;
margin-bottom: 32px;
position: relative;
}
.back-button {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding: 8px 16px;
font-size: 16px;
color: var(--color-primary);
cursor: pointer;
transition: transform 0.2s ease;
}
.back-button:hover {
transform: translateX(-4px);
}
.back-icon {
font-size: 20px;
}
.page-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--color-text);
}
.form-card {
background: var(--bg-card);
padding: 32px;
border-radius: 16px;
box-shadow: var(--shadow-lg);
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 15px;
font-weight: 500;
color: var(--color-text);
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--color-border);
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s ease;
outline: none;
background: var(--bg-card);
color: var(--color-text);
}
.form-group input:focus {
border-color: var(--color-primary);
}
.form-group input::placeholder {
color: var(--color-text-muted);
}
.submit-button {
width: 100%;
padding: 14px;
background: var(--color-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 8px;
}
.submit-button:hover {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-primary);
}
.submit-button:active {
transform: translateY(0);
}
/* 桌面端样式 */
@media screen and (min-width: 768px) {
.container {
padding: 32px;
}
.form-card {
padding: 40px;
}
}
/* 平板端样式 */
@media screen and (max-width: 767px) and (min-width: 481px) {
.container {
padding: 20px;
}
.form-card {
padding: 24px;
}
.page-title {
font-size: 22px;
}
}
/* 移动端样式 */
@media screen and (max-width: 480px) {
.container {
padding: 16px;
}
.nav-bar {
margin-bottom: 24px;
}
.back-button {
padding: 6px 12px;
font-size: 14px;
}
.back-icon {
font-size: 18px;
}
.page-title {
font-size: 20px;
}
.form-card {
padding: 20px;
border-radius: 12px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-size: 14px;
margin-bottom: 6px;
}
.form-group input {
padding: 10px 14px;
font-size: 14px;
}
.submit-button {
padding: 12px;
font-size: 14px;
}
}
/* 特小屏幕样式 */
@media screen and (max-width: 320px) {
.container {
padding: 12px;
}
.form-card {
padding: 16px;
}
.back-button {
padding: 4px 8px;
font-size: 13px;
}
.page-title {
font-size: 18px;
}
}
/* 确保所有元素使用 border-box */
* {
box-sizing: border-box;
}
/* 优化输入控件在移动端的表现 */
@media (hover: none) {
input {
font-size: 16px !important; /* 防止 iOS 自动缩放 */
}
}
/* 针对暗色模式的优化 */
@media (prefers-color-scheme: dark) {
.form-card {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
}
}

59
src/AddPage.tsx Normal file
View File

@ -0,0 +1,59 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { invoke } from "@tauri-apps/api/core";
import "./AddPage.css";
function AddPage() {
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [date, setDate] = useState("");
const handleSubmit = async () => {
try {
await invoke("add_anniversary", {
title,
startDate: date,
});
// 添加成功后返回主页
navigate("/");
} catch (error) {
console.error("Failed to add anniversary:", error);
}
};
return (
<div className="container">
<div className="nav-bar">
<button className="back-button" onClick={() => navigate("/")}>
<span className="back-icon"></span>
<span>Back</span>
</button>
<h1 className="page-title">Add New</h1>
</div>
<div className="form-card">
<div className="form-group">
<label>Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter anniversary title"
/>
</div>
<div className="form-group">
<label>Start Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<button className="submit-button" onClick={handleSubmit}>
Create Anniversary
</button>
</div>
</div>
);
}
export default AddPage;

1
src/App.css Normal file
View File

@ -0,0 +1 @@
/* App.css */

36
src/App.tsx Normal file
View File

@ -0,0 +1,36 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useEffect } from "react";
import HomePage from "./HomePage";
import DetailPage from "./DetailPage";
import AddPage from "./AddPage";
import "./themes.css";
const THEMES = [
"blue",
"green",
"purple",
"orange",
"pink",
"cyan",
"dark",
"gradient"
];
function App() {
useEffect(() => {
const randomTheme = THEMES[Math.floor(Math.random() * THEMES.length)];
document.documentElement.setAttribute('data-theme', randomTheme);
}, []);
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/detail/:id" element={<DetailPage />} />
<Route path="/add" element={<AddPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;

79
src/DetailPage.css Normal file
View File

@ -0,0 +1,79 @@
.container {
padding: 24px;
background: var(--bg-secondary);
min-height: 100vh;
max-width: 800px;
margin: 0 auto;
}
.nav-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
position: relative;
}
.back-button {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--color-primary);
font-size: 16px;
cursor: pointer;
padding: 8px 16px;
transition: transform 0.2s ease;
}
.back-button:hover {
transform: translateX(-4px);
}
.delete-button {
background-color: var(--color-danger);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
transition: all 0.2s ease;
}
.delete-button:hover {
background-color: var(--color-danger-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 68, 68, 0.2);
}
.card {
background: var(--bg-card);
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-lg);
text-align: center;
}
.title {
font-size: 32px;
color: var(--color-text);
margin: 0 0 24px 0;
font-weight: 600;
text-align: center;
}
.days {
font-size: 64px;
font-weight: 700;
color: var(--color-primary);
margin: 32px 0;
line-height: 1;
}
.start-date {
font-size: 16px;
color: var(--color-text-muted);
margin-top: 16px;
}

66
src/DetailPage.tsx Normal file
View File

@ -0,0 +1,66 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { invoke } from "@tauri-apps/api/core";
import "./DetailPage.css";
interface Anniversary {
id: number;
title: string;
start_date: string;
days: number;
}
function DetailPage() {
const { id } = useParams(); // 获取路由参数
const navigate = useNavigate(); // 获取导航函数
const [anniversary, setAnniversary] = useState<Anniversary>({
id: 0,
title: "",
start_date: "",
days: 0,
});
useEffect(() => {
const getAnniversaryData = async () => {
if (id) {
const result = await invoke<Anniversary>("get_anniversary_by_id", {
id: parseInt(id),
});
setAnniversary(result);
}
};
getAnniversaryData();
}, [id]);
const handleDelete = async () => {
if (id) {
try {
await invoke("delete_anniversary", { id: parseInt(id) });
navigate("/"); // 删除成功后返回主页
} catch (error) {
console.error("Failed to delete anniversary:", error);
}
}
};
return (
<div className="container">
<div className="nav-bar">
<button className="back-button" onClick={() => navigate("/")}>
<span className="back-icon"></span>
<span>Back</span>
</button>
<button className="delete-button" onClick={handleDelete}>
Delete
</button>
</div>
<div className="card">
<h1 className="title">{anniversary.title}</h1>
<div className="days">{anniversary.days}</div>
<div className="start-date">Start Date: {anniversary.start_date}</div>
</div>
</div>
);
}
export default DetailPage;

181
src/HomePage.css Normal file
View File

@ -0,0 +1,181 @@
.container {
padding: 16px;
background: var(--bg-primary);
min-height: 100vh;
margin: 0 auto;
}
.header-bar {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 32px;
position: relative;
padding: 16px 0;
}
h1 {
margin: 0;
font-size: 28px;
color: var(--color-text);
font-weight: 600;
letter-spacing: 0.5px;
}
.add-button {
position: absolute;
right: 20px;
background-color: var(--color-primary);
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-primary);
}
.add-button:hover {
background-color: var(--color-primary-hover);
transform: scale(1.05);
box-shadow: var(--shadow-primary);
}
.plus-icon {
color: white;
font-size: 24px;
line-height: 1;
display: block;
margin-top: -2px;
}
.anniversary-list {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
max-width: 800px;
margin: 0 auto;
}
.anniversary-card {
background: var(--bg-card);
padding: 16px;
border-radius: 8px;
box-shadow: var(--shadow-sm);
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.anniversary-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.anniversary-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--color-text);
}
.anniversary-info {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--color-text-secondary);
}
.days-count {
font-size: 20px;
color: var(--color-primary);
font-weight: 600;
}
/* 桌面端样式 */
@media screen and (min-width: 768px) {
.container {
max-width: 1200px;
padding: 24px;
}
.anniversary-list {
padding: 0 20px;
}
.anniversary-card {
padding: 20px;
}
}
/* 移动端样式 */
@media screen and (max-width: 767px) {
.container {
padding: 12px;
}
h1 {
font-size: 24px;
}
.add-button {
width: 36px;
height: 36px;
right: 12px;
}
.plus-icon {
font-size: 20px;
}
.anniversary-card {
padding: 14px;
}
.anniversary-title {
font-size: 16px;
}
.days-count {
font-size: 18px;
}
}
/* 针对特别窄的屏幕 */
@media screen and (max-width: 320px) {
.container {
padding: 8px;
}
h1 {
font-size: 20px;
}
.add-button {
width: 32px;
height: 32px;
right: 8px;
}
.plus-icon {
font-size: 18px;
}
.anniversary-title {
font-size: 14px;
}
.days-count {
font-size: 16px;
}
}
/* 确保所有元素都使用 border-box */
* {
box-sizing: border-box;
}

61
src/HomePage.tsx Normal file
View File

@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useNavigate } from "react-router-dom";
import "./HomePage.css";
interface Anniversary {
id: number;
title: string;
start_date: string;
days: number;
}
function HomePage() {
const [anniversaries, setAnniversaries] = useState<Anniversary[]>([]);
useEffect(() => {
const loadAnniversaries = async () => {
const result = await invoke<Anniversary[]>("get_anniversaries");
setAnniversaries(result);
};
loadAnniversaries();
}, []);
// 获取导航函数
const navigate = useNavigate();
// 点击卡片时跳转到详情页
const handleCardClick = (id: number) => {
navigate(`/detail/${id}`); // 例如:/detail/1
};
return (
<div className="container">
<div className="header-bar">
<h1>Count Down</h1>
<button className="add-button" onClick={() => navigate("/add")}>
<span className="plus-icon">+</span>
</button>
</div>
<div className="anniversary-list">
{anniversaries.map((item) => (
<div
key={item.id}
className="anniversary-card"
onClick={() => {
handleCardClick(item.id);
}}
>
<div className="anniversary-title">{item.title}</div>
<div className="anniversary-info">
<span className="days-count">{item.days}</span>
<span className="start-date">{item.start_date}</span>
</div>
</div>
))}
</div>
</div>
);
}
export default HomePage;

9
src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

153
src/themes.css Normal file
View File

@ -0,0 +1,153 @@
/* 主题系统 - CSS变量定义 */
/* 默认主题(蓝色) */
:root[data-theme="blue"] {
--bg-primary: #f5f5f5;
--bg-secondary: #f8f9fa;
--bg-card: #ffffff;
--color-primary: #4a90e2;
--color-primary-hover: #357abd;
--color-text: #2c3e50;
--color-text-secondary: #666;
--color-text-muted: #6c757d;
--color-border: #e9ecef;
--color-danger: #ff4444;
--color-danger-hover: #e60000;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 2px 8px rgba(74, 144, 226, 0.2);
}
/* 绿色主题 */
:root[data-theme="green"] {
--bg-primary: #f0f8f0;
--bg-secondary: #f5fbf5;
--bg-card: #ffffff;
--color-primary: #28a745;
--color-primary-hover: #218838;
--color-text: #2d5016;
--color-text-secondary: #555;
--color-text-muted: #6c7b6c;
--color-border: #d4edda;
--color-danger: #dc3545;
--color-danger-hover: #c82333;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 2px 8px rgba(40, 167, 69, 0.2);
}
/* 紫色主题 */
:root[data-theme="purple"] {
--bg-primary: #f8f5ff;
--bg-secondary: #faf7ff;
--bg-card: #ffffff;
--color-primary: #8b5cf6;
--color-primary-hover: #7c3aed;
--color-text: #4c1d95;
--color-text-secondary: #666;
--color-text-muted: #7c6c95;
--color-border: #e9d5ff;
--color-danger: #ef4444;
--color-danger-hover: #dc2626;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 2px 8px rgba(139, 92, 246, 0.2);
}
/* 橙色主题 */
:root[data-theme="orange"] {
--bg-primary: #fff7ed;
--bg-secondary: #fffbf5;
--bg-card: #ffffff;
--color-primary: #f97316;
--color-primary-hover: #ea580c;
--color-text: #9a3412;
--color-text-secondary: #666;
--color-text-muted: #a16c3c;
--color-border: #fed7aa;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 2px 8px rgba(249, 115, 22, 0.2);
}
/* 粉色主题 */
:root[data-theme="pink"] {
--bg-primary: #fdf2f8;
--bg-secondary: #fef7f0;
--bg-card: #ffffff;
--color-primary: #ec4899;
--color-primary-hover: #db2777;
--color-text: #831843;
--color-text-secondary: #666;
--color-text-muted: #a15a7a;
--color-border: #f9a8d4;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 2px 8px rgba(236, 72, 153, 0.2);
}
/* 青色主题 */
:root[data-theme="cyan"] {
--bg-primary: #ecfdf5;
--bg-secondary: #f0fdfa;
--bg-card: #ffffff;
--color-primary: #06b6d4;
--color-primary-hover: #0891b2;
--color-text: #164e63;
--color-text-secondary: #666;
--color-text-muted: #4c7a7a;
--color-border: #a7f3d0;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 2px 8px rgba(6, 182, 212, 0.2);
}
/* 暗色主题 */
:root[data-theme="dark"] {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-card: #374151;
--color-primary: #60a5fa;
--color-primary-hover: #3b82f6;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
--color-text-muted: #9ca3af;
--color-border: #4b5563;
--color-danger: #ef4444;
--color-danger-hover: #dc2626;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-primary: 0 2px 8px rgba(96, 165, 250, 0.3);
}
/* 渐变主题 */
:root[data-theme="gradient"] {
--bg-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--bg-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--bg-card: rgba(255, 255, 255, 0.95);
--color-primary: #667eea;
--color-primary-hover: #5a6fd8;
--color-text: #2d3748;
--color-text-secondary: #4a5568;
--color-text-muted: #718096;
--color-border: rgba(255, 255, 255, 0.3);
--color-danger: #e53e3e;
--color-danger-hover: #c53030;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
--shadow-primary: 0 2px 8px rgba(102, 126, 234, 0.3);
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

32
vite.config.ts Normal file
View File

@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));