init: Wardrobe 衣物管理系统 - 项目首页、OTA 升级、Android CI/CD

- 响应式项目首页 (Landing, 含 R2 APK 下载) + 路由 /home 调整
- OTA 升级: tauri-plugin-hotswap (lib.rs/main.rs 拆分, 客户端热替换)
- OTA 服务端 (check/bundle/manifest-web) + build-ota/genkey/upload 脚本
- 密钥/凭据统一存配置中心 (signing + ota + cloudflare 分组)
- Android CI (android-* 分支) + OTA CI (手动触发), 产物上传 R2
- 修复 tsc/构建阻塞问题
This commit is contained in:
jif2.zhang
2026-09-11 15:46:42 +08:00
commit 38b5e3190a
62 changed files with 17963 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
import React, { useEffect } from "react";
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
import { useAppStore } from "./stores/app";
import { initOta } from "./api/ota";
import Login from "./pages/Login";
import Landing from "./pages/Landing";
import Home from "./pages/Home";
import Closet from "./pages/Closet";
import AddClothing from "./pages/AddClothing";
import ClothingDetail from "./pages/ClothingDetail";
import Outfits from "./pages/Outfits";
import Calendar from "./pages/Calendar";
import Profile from "./pages/Profile";
import Layout from "./components/Layout";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const user = useAppStore((s) => s.user);
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function RouterGate() {
const user = useAppStore((s) => s.user);
const navigate = useNavigate();
useEffect(() => {
// Tauri 端: 告知 hotswap 当前资源已就绪 (Web 端 no-op)
initOta();
}, []);
useEffect(() => {
const saved = localStorage.getItem("wardrobe_user");
if (saved && !user) {
const parsed = JSON.parse(saved);
useAppStore.getState().setUser(parsed.user, parsed.token);
navigate("/home", { replace: true });
} else if (user) {
localStorage.setItem("wardrobe_user", JSON.stringify(useAppStore.getState()));
}
}, [user, navigate]);
return (
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/home" element={<Home />} />
<Route path="/closet" element={<Closet />} />
<Route path="/closet/:id" element={<ClothingDetail />} />
<Route path="/add" element={<AddClothing />} />
<Route path="/outfits" element={<Outfits />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/profile" element={<Profile />} />
</Route>
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<RouterGate />
</BrowserRouter>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { invoke } from "@tauri-apps/api/core";
export interface ClothingItem {
id: string;
user_id: string;
name: string;
category: string;
color: string;
brand?: string;
material?: string;
seasons: string[];
occasions: string[];
formality: number;
thickness: number;
image_path?: string;
purchase_date?: string;
purchase_price?: number;
last_cleaned?: string;
clean_method?: string;
wear_count: number;
last_worn?: string;
is_favorite: boolean;
created_at: string;
updated_at: string;
}
export interface Outfit {
id: string;
user_id: string;
name: string;
item_ids: string[];
occasion?: string;
season?: string;
match_score?: number;
ai_analysis?: string;
is_saved: boolean;
created_at: string;
}
export interface DailyRecord {
id: string;
user_id: string;
date: string;
outfit_id?: string;
weather_temp?: number;
weather_condition?: string;
notes?: string;
created_at: string;
}
export interface User {
id: string;
email?: string;
phone?: string;
nickname: string;
avatar_path?: string;
created_at: string;
}
export interface AuthResult {
success: boolean;
message: string;
user?: User;
token?: string;
}
export interface WardrobeStats {
total_items: number;
total_outfits: number;
utilization_rate: number;
category_counts: Record<string, number>;
favorite_count: number;
unworn_30d: number;
needs_cleaning: number;
}
export interface McpTool {
name: string;
description: string;
input_schema: string;
}
// ── Auth ──
export const sendCode = (target: string) => invoke<string>("send_code", { target });
export const register = (email: string | null, phone: string | null, code: string, nickname: string) =>
invoke<AuthResult>("register", { email, phone, code, nickname });
export const login = (email: string | null, phone: string | null, code: string) =>
invoke<AuthResult>("login", { email, phone, code });
// ── Clothing ──
export const addClothing = (userId: string, name: string, category: string, color: string, brand?: string, seasons?: string[], occasions?: string[]) =>
invoke<ClothingItem>("add_clothing", { userId, name, category, color, brand, seasons, occasions });
export const listClothing = (userId: string, category?: string) =>
invoke<ClothingItem[]>("list_clothing", { userId, category });
export const getClothing = (id: string) =>
invoke<ClothingItem | null>("get_clothing", { id });
export const updateClothing = (id: string, name: string, category: string, color: string) =>
invoke<void>("update_clothing", { id, name, category, color });
export const deleteClothing = (id: string) =>
invoke<void>("delete_clothing", { id });
// ── Outfits ──
export const addOutfit = (userId: string, name: string, itemIds: string[], occasion?: string, season?: string) =>
invoke<Outfit>("add_outfit", { userId, name, itemIds, occasion, season });
export const listOutfits = (userId: string) =>
invoke<Outfit[]>("list_outfits", { userId });
// ── Daily Records ──
export const recordDaily = (userId: string, date: string, outfitId?: string, notes?: string) =>
invoke<DailyRecord>("record_daily", { userId, date, outfitId, notes });
export const listDailyRecords = (userId: string, limit?: number) =>
invoke<DailyRecord[]>("list_daily_records", { userId, limit });
// ── Stats ──
export const getStats = (userId: string) =>
invoke<WardrobeStats>("get_stats", { userId });
// ── MCP ──
export const mcpListTools = () =>
invoke<McpTool[]>("mcp_list_tools");
export const mcpCallTool = (toolName: string, args: Record<string, unknown>) =>
invoke<unknown>("mcp_call_tool", { toolName, args });
+181
View File
@@ -0,0 +1,181 @@
// 前端 OTA 封装
// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...)
// - Web 浏览器: 只走我们自己的 /api/ota/manifest-web, 提示用户手动刷新 (Vite 资源指纹会失效)
//
// 设计原则:
// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
import { APP_VERSION, BUILD_ID } from "../version";
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
// 拼接绝对 URL: web 模式同源 (OTA 服务端同时托管 dist), Tauri 不走这里
function resolveUrl(p: string) {
const base = import.meta.env.VITE_API_BASE as string | undefined;
return base ? base + p : p;
}
export interface OtaUpdateInfo {
version: string;
sequence?: number;
notes?: string;
mandatory?: boolean;
bundle_size?: number;
}
export interface OtaCheckResult {
available: boolean;
reason: "no_package" | "same_version" | "ota_disabled" | "unsupported" | "ok";
info: OtaUpdateInfo | null;
}
// 当前版本信息 (前端展示用)
export function getCurrentVersionInfo() {
return { version: APP_VERSION, build: BUILD_ID };
}
// 启动时: 确认当前资源可用 (Tauri only)
// 失败/异常都不抛, 仅 console.warn
export function initOta() {
if (!inTauri) return;
// 动态 import: web 模式根本不打这个 chunk
import("tauri-plugin-hotswap-api")
.then((mod) => mod.notifyReady())
.then(() => console.log("[ota] notifyReady ok"))
.catch((e) => console.warn("[ota] notifyReady failed:", e));
}
// 检查更新, 返回统一结构, 调用方不关心是 Tauri 还是 web
export async function checkOtaUpdate(): Promise<OtaCheckResult> {
if (!inTauri) {
return checkOtaUpdateWeb();
}
return checkOtaUpdateTauri();
}
async function checkOtaUpdateTauri(): Promise<OtaCheckResult> {
const { checkUpdate } = await import("tauri-plugin-hotswap-api");
const r = await checkUpdate();
if (!r.available) {
return { available: false, reason: "no_package", info: null };
}
return {
available: true,
reason: "ok",
info: {
version: r.version ?? "0.0.0",
sequence: r.sequence ?? undefined,
notes: r.notes ?? undefined,
mandatory: r.mandatory ?? undefined,
bundle_size: r.bundle_size ?? undefined,
},
};
}
async function checkOtaUpdateWeb(): Promise<OtaCheckResult> {
// Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
try {
const res = await fetch(resolveUrl("/api/ota/manifest-web"), { method: "GET" });
if (!res.ok) {
return { available: false, reason: "unsupported", info: null };
}
const data = await res.json();
if (!data.available) return { available: false, reason: "no_package", info: null };
if (data.build_id && data.build_id === BUILD_ID) {
return { available: false, reason: "same_version", info: null };
}
return {
available: true,
reason: "ok",
info: {
version: data.version,
notes: data.note || "",
mandatory: false,
bundle_size: data.size || 0,
},
};
} catch (e) {
console.warn("[ota] web check failed:", e);
return { available: false, reason: "unsupported", info: null };
}
}
// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理
// onProgress(received, total) 进度回调
// 成功后必须 reload, 新资源才会生效
export async function applyOtaUpdate(onProgress?: (received: number, total: number) => void): Promise<string> {
if (!inTauri) {
throw new Error("OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面");
}
const mod = await import("tauri-plugin-hotswap-api");
// 注册一次性进度监听 (applyUpdate 期间触发)
let unlisten: (() => void) | null = null;
let unlistenLifecycle: (() => void) | null = null;
if (onProgress) {
try {
unlisten = ((await mod.onDownloadProgress((p) =>
onProgress(p.downloaded ?? 0, p.total ?? 0)
)) as () => void) ?? null;
} catch (e) {
console.warn("[ota] onDownloadProgress 监听失败:", e);
}
}
// 监听 lifecycle 事件, 捕获失败详情
let lifecycleError = "";
try {
unlistenLifecycle =
((await mod.onLifecycle((ev: { event: string; error?: string | null }) => {
if (ev.event === "download-error" || ev.event === "apply" || ev.event === "check-error") {
if (ev.error) {
lifecycleError = ev.error;
console.error("[ota] lifecycle", ev.event, "error:", ev.error);
}
}
})) as () => void) ?? null;
} catch (e) {
console.warn("[ota] onLifecycle 监听失败:", e);
}
try {
return (await mod.applyUpdate()) as string;
} catch (e: unknown) {
const base = (e as Error)?.message || "升级失败";
const detail = lifecycleError ? ` (${lifecycleError})` : "";
const err = new Error(base + detail);
throw err;
} finally {
if (unlisten) {
try { unlisten(); } catch { /* ignore */ }
}
if (unlistenLifecycle) {
try { unlistenLifecycle(); } catch { /* ignore */ }
}
}
}
// 回滚到内置 dist (Tauri only, OTA 出错时手动恢复)
export async function rollbackOta(): Promise<void> {
if (!inTauri) throw new Error("仅 Tauri 客户端可用");
const mod = await import("tauri-plugin-hotswap-api");
await mod.rollback();
}
// 取当前 OTA 状态 (Tauri only)
export async function getOtaVersionInfo(): Promise<unknown | null> {
if (!inTauri) return null;
try {
const mod = await import("tauri-plugin-hotswap-api");
return await mod.getVersionInfo();
} catch (e) {
console.warn("[ota] getVersionInfo failed:", e);
return null;
}
}
// reload 当前页 (新资源生效)
export function reloadApp() {
window.location.reload();
}
+52
View File
@@ -0,0 +1,52 @@
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { useAppStore } from "../stores/app";
const NAV_ICONS: Record<string, string> = {
home: "🏠",
outfits: "💡",
closet: "👕",
profile: "👤",
};
export default function Layout() {
const navigate = useNavigate();
const location = useLocation();
const user = useAppStore((s) => s.user);
const isActive = (path: string) =>
path === "/home" ? location.pathname === "/home" || location.pathname === "/" : location.pathname.startsWith(path);
const goAdd = () => {
if (!user) return;
navigate("/add");
};
return (
<div className="app-shell">
<div className="app-content">
<Outlet />
</div>
<nav className="bottom-nav">
<button className={"nav-item" + (isActive("/home") ? " active" : "")} onClick={() => navigate("/home")}>
<span className="nav-icon">{NAV_ICONS.home}</span>
<span>首页</span>
</button>
<button className={"nav-item" + (isActive("/outfits") ? " active" : "")} onClick={() => navigate("/outfits")}>
<span className="nav-icon">{NAV_ICONS.outfits}</span>
<span>搭配</span>
</button>
<button className="nav-item center-nav-btn" onClick={goAdd}>
<span className="fab-circle">+</span>
</button>
<button className={"nav-item" + (isActive("/closet") ? " active" : "")} onClick={() => navigate("/closet")}>
<span className="nav-icon">{NAV_ICONS.closet}</span>
<span>衣橱</span>
</button>
<button className={"nav-item" + (isActive("/profile") ? " active" : "")} onClick={() => navigate("/profile")}>
<span className="nav-icon">{NAV_ICONS.profile}</span>
<span>我的</span>
</button>
</nav>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+162
View File
@@ -0,0 +1,162 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { addClothing } from "../api";
import { useAppStore } from "../stores/app";
import { CATEGORIES, SEASONS, OCCASIONS } from "../types";
const COLORS = [
{ hex: "#1a1a1a", label: "黑" },
{ hex: "#f5f5f5", label: "白" },
{ hex: "#4a6fa5", label: "蓝" },
{ hex: "#8b7355", label: "棕" },
{ hex: "#c0392b", label: "红" },
{ hex: "#27ae60", label: "绿" },
{ hex: "#f39c12", label: "黄" },
{ hex: "#8e44ad", label: "紫" },
] as const;
export default function AddClothing() {
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const addItem = useAppStore((s) => s.addClothingItem);
const [category, setCategory] = useState<string>(CATEGORIES[0].key);
const [name, setName] = useState("");
const [color, setColor] = useState<string>(COLORS[0].hex);
const [brand, setBrand] = useState("");
const [seasons, setSeasons] = useState<string[]>(["春", "秋"]);
const [occasions, setOccasions] = useState<string[]>(["通勤"]);
const [formality, setFormality] = useState(50);
const [thickness, setThickness] = useState(30);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const toggleTag = (list: string[], value: string, setter: (v: string[]) => void) => {
setter(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]);
};
const handleSave = async () => {
if (!user) return;
if (!name.trim()) return setError("请输入衣物名称");
setSaving(true);
setError("");
try {
const item = await addClothing(user.id, name.trim(), category, color, brand || undefined, seasons, occasions);
addItem(item);
navigate("/closet", { replace: true });
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<div>
<div className="page-header">
<button style={{ fontSize: 20, background: "none" }} onClick={() => navigate(-1)}>←</button>
<h1 className="page-title" style={{ fontSize: 20 }}>添加衣物</h1>
<button style={{ fontSize: 14, fontWeight: 600, background: "none" }} onClick={handleSave}>保存</button>
</div>
<div
className="card"
style={{ aspectRatio: "1", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", border: "2px dashed var(--border)", background: "transparent", marginBottom: 16 }}
>
<span style={{ fontSize: 44 }}>📷</span>
<p style={{ fontSize: 14, color: "var(--text-secondary)", marginTop: 8 }}>点击拍照或从相册选择</p>
<small style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 4 }}>支持 AI 自动识别</small>
</div>
<div className="card" style={{ padding: "10px 14px", marginBottom: 16 }}>
<p style={{ fontSize: 13, color: "#B8734A" }}>🤖 AI 将自动识别品类、颜色、材质</p>
</div>
<p style={{ fontSize: 16, fontWeight: 600, margin: "0 0 12px" }}>基础信息</p>
<div className="card" style={{ marginBottom: 16 }}>
<div className="input-group">
<label>品类</label>
<select className="input" value={category} onChange={(e) => setCategory(e.target.value)}>
{CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.icon} {c.label}</option>
))}
</select>
</div>
<div className="input-group">
<label>名称</label>
<input className="input" placeholder="如:白色衬衫" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="input-group">
<label>颜色</label>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{COLORS.map((c) => (
<button
key={c.hex}
onClick={() => setColor(c.hex)}
style={{
width: 34,
height: 34,
borderRadius: "50%",
background: c.hex,
border: c.hex === color ? "3px solid var(--text-primary)" : c.hex === "#f5f5f5" ? "1px solid var(--border)" : "none",
position: "relative",
}}
/>
))}
</div>
</div>
<div className="input-group" style={{ marginBottom: 0 }}>
<label>品牌</label>
<input className="input" placeholder="如:优衣库" value={brand} onChange={(e) => setBrand(e.target.value)} />
</div>
</div>
<p style={{ fontSize: 16, fontWeight: 600, margin: "0 0 12px" }}>属性标签</p>
<div className="card" style={{ marginBottom: 16 }}>
<div className="input-group">
<label>季节</label>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{SEASONS.map((s) => (
<button
key={s}
className={"tag-pill" + (seasons.includes(s) ? " tag-pill-active" : "")}
onClick={() => toggleTag(seasons, s, setSeasons)}
>
{s}
</button>
))}
</div>
</div>
<div className="input-group">
<label>场合</label>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{OCCASIONS.map((o) => (
<button
key={o}
className={"tag-pill" + (occasions.includes(o) ? " tag-pill-active" : "")}
onClick={() => toggleTag(occasions, o, setOccasions)}
>
{o}
</button>
))}
</div>
</div>
<div className="input-group">
<label>正式度</label>
<input type="range" min={0} max={100} value={formality} onChange={(e) => setFormality(Number(e.target.value))} style={{ width: "100%" }} />
</div>
<div className="input-group" style={{ marginBottom: 0 }}>
<label>厚度</label>
<input type="range" min={0} max={100} value={thickness} onChange={(e) => setThickness(Number(e.target.value))} style={{ width: "100%" }} />
</div>
</div>
{error && <p style={{ fontSize: 13, color: "var(--danger)", marginBottom: 12 }}>{error}</p>}
<button className="btn btn-primary" disabled={saving} onClick={handleSave}>
{saving ? "保存中..." : "保存到衣橱"}
</button>
<div style={{ height: 24 }} />
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { listDailyRecords } from "../api";
import { useAppStore } from "../stores/app";
export default function Calendar() {
const user = useAppStore((s) => s.user);
const setDailyRecords = useAppStore((s) => s.setDailyRecords);
const dailyRecords = useAppStore((s) => s.dailyRecords);
const [month, setMonth] = useState({ year: 2026, month: 9 });
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) return;
(async () => {
try {
const records = await listDailyRecords(user.id, 30);
setDailyRecords(records);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
})();
}, [user]);
const daysInMonth = new Date(month.year, month.month, 0).getDate();
const firstDay = new Date(month.year, month.month - 1, 1).getDay();
const today = new Date().getDate();
const recordDates = new Set(dailyRecords.map((r) => r.date.slice(8, 10)));
const prevMonth = () => setMonth((m) => (m.month === 1 ? { year: m.year - 1, month: 12 } : { ...m, month: m.month - 1 }));
const nextMonth = () => setMonth((m) => (m.month === 12 ? { year: m.year + 1, month: 1 } : { ...m, month: m.month + 1 }));
const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
return (
<div>
<div className="page-header">
<button style={{ fontSize: 20, background: "none" }} onClick={() => window.history.back()}>←</button>
<h1 className="page-title" style={{ fontSize: 20 }}>穿搭日历</h1>
<div style={{ width: 24 }} />
</div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<button style={{ fontSize: 20, background: "none" }} onClick={prevMonth}>‹</button>
<p style={{ fontSize: 16, fontWeight: 600 }}>{month.year}年{month.month}月</p>
<button style={{ fontSize: 20, background: "none" }} onClick={nextMonth}>›</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", textAlign: "center", marginBottom: 8 }}>
{weekdays.map((d) => <span key={d} style={{ fontSize: 12, color: "var(--text-tertiary)" }}>{d}</span>)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4 }}>
{Array.from({ length: firstDay }).map((_, i) => <div key={`e${i}`} />)}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const isToday = day === today;
const hasRecord = recordDates.has(String(day).padStart(2, "0"));
return (
<div
key={day}
style={{
width: 38,
height: 38,
margin: "0 auto",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
position: "relative",
background: isToday ? "var(--text-primary)" : hasRecord ? "var(--accent-light)" : "transparent",
color: isToday ? "#fff" : "var(--text-primary)",
fontWeight: isToday ? 600 : 400,
}}
>
{day}
{hasRecord && !isToday && <span style={{ position: "absolute", bottom: 3, width: 4, height: 4, borderRadius: "50%", background: "var(--accent)" }} />}
</div>
);
})}
</div>
</div>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: "0 0 10px" }}>最近穿搭</h3>
{loading ? (
<div style={{ padding: 40, textAlign: "center" }}>加载中...</div>
) : dailyRecords.length === 0 ? (
<div className="card" style={{ textAlign: "center", padding: 40 }}>
<p style={{ fontSize: 36, marginBottom: 10 }}>📅</p>
<p style={{ fontSize: 14, color: "var(--text-secondary)" }}>暂无穿搭记录</p>
</div>
) : (
dailyRecords.map((r) => (
<div key={r.id} className="card" style={{ marginBottom: 8, display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ textAlign: "center", minWidth: 40 }}>
<p style={{ fontSize: 18, fontWeight: 700 }}>{r.date.slice(8, 10)}</p>
<small style={{ fontSize: 10, color: "var(--text-tertiary)" }}>{r.date.slice(5, 7)}月</small>
</div>
<div style={{ flex: 1 }}>
<p style={{ fontSize: 14, fontWeight: 500 }}>{r.notes || "今日穿搭"}</p>
{r.weather_condition && <small style={{ fontSize: 12, color: "var(--text-tertiary)" }}>{r.weather_condition} · {r.weather_temp}°C</small>}
</div>
</div>
))
)}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listClothing } from "../api";
import { useAppStore } from "../stores/app";
import { CATEGORIES } from "../types";
export default function Closet() {
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const clothing = useAppStore((s) => s.clothing);
const setClothing = useAppStore((s) => s.setClothing);
const [view, setView] = useState<"grid" | "list">("grid");
const [category, setCategory] = useState("all");
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) return;
(async () => {
try {
const items = await listClothing(user.id, category === "all" ? undefined : category);
setClothing(items);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
})();
}, [user, category]);
if (!user) return null;
const filtered = clothing.filter(
(c) => !query || c.name.toLowerCase().includes(query.toLowerCase()) || c.color.includes(query)
);
const catIcon = (key: string) => CATEGORIES.find((c) => c.key === key)?.icon ?? "👕";
return (
<div>
<div className="page-header">
<h1 className="page-title">我的衣橱</h1>
<button style={{ fontSize: 22, background: "none" }} onClick={() => navigate("/add")}>+</button>
</div>
<div className="card" style={{ padding: "10px 14px", marginBottom: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 16 }}>🔍</span>
<input
className="input"
style={{ border: "none", padding: 0, boxShadow: "none" }}
placeholder="搜索衣物..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<div style={{ display: "flex", gap: 6, overflowX: "auto", marginBottom: 10, paddingBottom: 4 }}>
<button className={"tag-pill" + (category === "all" ? " tag-pill-active" : "")} style={{ flexShrink: 0 }} onClick={() => setCategory("all")}>
全部
</button>
{CATEGORIES.map((c) => (
<button
key={c.key}
className={"tag-pill" + (category === c.key ? " tag-pill-active" : "")}
style={{ flexShrink: 0 }}
onClick={() => setCategory(c.key)}
>
{c.label}
</button>
))}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginBottom: 12 }}>
<button
style={{ padding: "8px 12px", borderRadius: 8, fontSize: 16, background: view === "grid" ? "var(--text-primary)" : "transparent", color: view === "grid" ? "#fff" : "var(--text-tertiary)" }}
onClick={() => setView("grid")}
>
▦
</button>
<button
style={{ padding: "8px 12px", borderRadius: 8, fontSize: 16, background: view === "list" ? "var(--text-primary)" : "transparent", color: view === "list" ? "#fff" : "var(--text-tertiary)" }}
onClick={() => setView("list")}
>
☰
</button>
</div>
{loading ? (
<div style={{ textAlign: "center", padding: 60 }}>
<p style={{ fontSize: 14, color: "var(--text-secondary)" }}>加载中...</p>
</div>
) : filtered.length === 0 ? (
<div className="card" style={{ textAlign: "center", padding: 48 }}>
<p style={{ fontSize: 40, marginBottom: 12 }}>👕</p>
<p style={{ fontSize: 15, fontWeight: 600 }}>衣橱还是空的</p>
<p style={{ fontSize: 13, color: "var(--text-secondary)", margin: "6px 0 16px" }}>点击下方按钮添加第一件衣物</p>
<button className="btn btn-primary" onClick={() => navigate("/add")}>+ 添加衣物</button>
</div>
) : view === "grid" ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
{filtered.map((item) => (
<button key={item.id} className="card" style={{ padding: 0, overflow: "hidden" }} onClick={() => navigate(`/closet/${item.id}`)}>
<div style={{ aspectRatio: "1", background: "var(--bg-warm)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 32 }}>
{catIcon(item.category)}
</div>
<div style={{ padding: "8px 10px", textAlign: "left" }}>
<p style={{ fontSize: 12, fontWeight: 500 }}>{item.name}</p>
<small style={{ fontSize: 10, color: "var(--text-tertiary)" }}>{item.wear_count}次/月</small>
</div>
</button>
))}
</div>
) : (
<div>
{filtered.map((item) => (
<button key={item.id} className="card" style={{ width: "100%", marginBottom: 8, display: "flex", alignItems: "center", gap: 12, textAlign: "left" }} onClick={() => navigate(`/closet/${item.id}`)}>
<span style={{ fontSize: 24, background: "var(--bg-warm)", width: 44, height: 44, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center" }}>
{catIcon(item.category)}
</span>
<div style={{ flex: 1 }}>
<p style={{ fontSize: 14, fontWeight: 500 }}>{item.name}</p>
<small style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
{item.category} · {item.color} · {item.seasons.join("/")}
</small>
</div>
<span style={{ fontSize: 12, color: "var(--text-secondary)" }}>{item.wear_count}次/月</span>
</button>
))}
</div>
)}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getClothing, deleteClothing, recordDaily } from "../api";
import { useAppStore } from "../stores/app";
import { CATEGORIES } from "../types";
export default function ClothingDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const [item, setItem] = useState<any>(null);
const [showDelete, setShowDelete] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
(async () => {
try {
const data = await getClothing(id);
setItem(data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
})();
}, [id]);
if (loading) return <div style={{ padding: 60, textAlign: "center" }}>加载中...</div>;
if (!item || !user) return <div style={{ padding: 60, textAlign: "center" }}>衣物不存在</div>;
const cat = CATEGORIES.find((c) => c.key === item.category);
const catLabel = cat ? `${cat.icon} ${cat.label}` : item.category;
const handleRecord = async () => {
try {
const today = new Date().toISOString().slice(0, 10);
await recordDaily(user.id, today, item.id, `今日穿搭: ${item.name}`);
window.alert("已记录今日穿搭");
} catch (e) {
console.error(e);
}
};
const handleDelete = async () => {
try {
await deleteClothing(item.id);
navigate("/closet", { replace: true });
} catch (e) {
console.error(e);
}
};
return (
<div>
<div className="page-header">
<button style={{ fontSize: 20, background: "none" }} onClick={() => navigate(-1)}>←</button>
<div style={{ display: "flex", gap: 8 }}>
<button style={{ fontSize: 18, background: "none" }} onClick={() => navigate(`/add?edit=${item.id}`)}>✏️</button>
<button style={{ fontSize: 18, background: "none", color: "var(--danger)" }} onClick={() => setShowDelete(true)}>🗑️</button>
</div>
</div>
<div style={{ width: "100%", aspectRatio: "1", background: "var(--bg-warm)", borderRadius: "var(--radius)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 90, marginBottom: 16 }}>
{cat?.icon ?? "👕"}
</div>
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{item.name}</h2>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 16 }}>
<span className="tag-pill">{catLabel}</span>
<span className="tag-pill" style={{ background: item.color, color: isLight(item.color) ? "#1a1a1a" : "#fff", border: "1px solid var(--border)" }}>{item.color}</span>
{item.seasons.map((s: string) => <span key={s} className="tag-pill">{s}</span>)}
{item.occasions.map((o: string) => <span key={o} className="tag-pill">{o}</span>)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 1, background: "var(--border)", borderRadius: "var(--radius-sm)", overflow: "hidden", marginBottom: 20 }}>
{[
{ num: item.wear_count, label: "本月穿着" },
{ num: item.wear_count * 6, label: "总穿着次数" },
{ num: daysSince(item.created_at), label: "拥有天数" },
].map((s, i) => (
<div key={i} style={{ background: "#fff", padding: 16, textAlign: "center" }}>
<p style={{ fontSize: 20, fontWeight: 700 }}>{s.num}</p>
<p style={{ fontSize: 11, color: "var(--text-secondary)", marginTop: 2 }}>{s.label}</p>
</div>
))}
</div>
<div style={{ marginBottom: 20 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 8 }}>基本信息</h3>
<div className="card" style={{ padding: "4px 16px" }}>
{[
["品类", catLabel],
["颜色", item.color],
["品牌", item.brand || "—"],
["材质", item.material || "—"],
["购买日期", item.purchase_date || "—"],
["购买价格", item.purchase_price ? `¥${item.purchase_price}` : "—"],
].map(([k, v]) => (
<div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "10px 0", borderBottom: "1px solid var(--border)", fontSize: 14 }}>
<span style={{ color: "var(--text-secondary)" }}>{k}</span>
<span>{v}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", padding: "10px 0", fontSize: 14 }}>
<span style={{ color: "var(--text-secondary)" }}>上次清洁</span>
<span>{item.last_cleaned || "从未清洁"}</span>
</div>
</div>
</div>
<button className="btn btn-primary" style={{ marginBottom: 10 }} onClick={handleRecord}>📅 记录今日穿搭</button>
<button className="btn btn-outline" onClick={() => navigate("/add?edit=" + item.id)}>✏️ 编辑信息</button>
{showDelete && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 50, padding: 40 }}>
<div className="card" style={{ width: "100%", maxWidth: 320 }}>
<h3 style={{ fontSize: 18, marginBottom: 8 }}>确认删除?</h3>
<p style={{ fontSize: 14, color: "var(--text-secondary)", marginBottom: 20 }}>删除后无法恢复</p>
<div style={{ display: "flex", gap: 10 }}>
<button className="btn btn-secondary" style={{ fontSize: 14 }} onClick={() => setShowDelete(false)}>取消</button>
<button className="btn btn-danger" style={{ fontSize: 14 }} onClick={handleDelete}>删除</button>
</div>
</div>
</div>
)}
</div>
);
}
function isLight(hex: string) {
const m = hex.replace("#", "");
const r = parseInt(m.slice(0, 2), 16);
const g = parseInt(m.slice(2, 4), 16);
const b = parseInt(m.slice(4, 6), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 150;
}
function daysSince(iso: string) {
const start = new Date(iso).getTime();
return Math.max(1, Math.floor((Date.now() - start) / 86400000));
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getStats, listClothing } from "../api";
import { useAppStore } from "../stores/app";
import { CATEGORIES } from "../types";
export default function Home() {
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const setStats = useAppStore((s) => s.setStats);
const stats = useAppStore((s) => s.stats);
const clothing = useAppStore((s) => s.clothing);
const setClothing = useAppStore((s) => s.setClothing);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) return;
(async () => {
try {
const [statsData, clothingData] = await Promise.all([getStats(user.id), listClothing(user.id)]);
setStats(statsData);
setClothing(clothingData);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
})();
}, [user]);
if (!user) return null;
const topUnworn = clothing.filter((c) => !c.last_worn || Date.now() - new Date(c.last_worn).getTime() > 30 * 86400000);
return (
<div>
<div className="page-header">
<div>
<p style={{ fontSize: 13, color: "var(--text-secondary)" }}>早上好 ☀️</p>
<h1 className="page-title">今日穿搭</h1>
</div>
<button style={{ fontSize: 22, background: "none" }} onClick={() => navigate("/add")}>+</button>
</div>
{loading ? (
<div style={{ textAlign: "center", padding: 80 }}>
<p style={{ fontSize: 14, color: "var(--text-secondary)" }}>加载中...</p>
</div>
) : (
<>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10 }}>
<span style={{ fontSize: 30 }}>🌤️</span>
<div>
<p style={{ fontSize: 20, fontWeight: 700 }}>24°C</p>
<p style={{ fontSize: 12, color: "var(--text-secondary)" }}>多云 · 适合轻薄外套</p>
</div>
</div>
<div style={{ display: "flex", gap: 6 }}>
<span className="tag-pill">春</span>
<span className="tag-pill">通勤</span>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "12px 0" }}>
<h2 style={{ fontSize: 17, fontWeight: 600 }}>推荐搭配</h2>
<button style={{ fontSize: 13, color: "var(--accent)", background: "none" }} onClick={() => navigate("/outfits")}>查看更多</button>
</div>
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginBottom: 12 }}>
{["👕", "👖", "🧥", "👟"].map((icon, i) => (
<div key={i} style={{ aspectRatio: "1", background: "var(--bg-warm)", borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 26 }}>
{icon}
</div>
))}
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span className="tag-pill">通勤</span>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>⭐ 4.8</span>
</div>
<p style={{ fontWeight: 600, fontSize: 15, marginBottom: 12 }}>简约通勤风</p>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-primary" style={{ padding: "10px 0", fontSize: 13 }}>保存到日历</button>
<button className="btn btn-secondary" style={{ padding: "10px 0", fontSize: 13 }}>换一套</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: 20 }}>
{[
{ num: stats?.total_items ?? 0, label: "衣物总数" },
{ num: stats?.total_outfits ?? 0, label: "搭配收藏" },
{ num: `${stats?.utilization_rate ?? 0}%`, label: "利用率" },
].map((s, i) => (
<div key={i} className="card" style={{ textAlign: "center", padding: "14px 8px" }}>
<p style={{ fontSize: 22, fontWeight: 700 }}>{s.num}</p>
<p style={{ fontSize: 11, color: "var(--text-secondary)", marginTop: 2 }}>{s.label}</p>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: 20 }}>
{CATEGORIES.map((cat) => (
<button
key={cat.key}
className="card"
style={{ textAlign: "center", padding: "14px 8px", display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}
onClick={() => navigate("/closet")}
>
<span style={{ fontSize: 24 }}>{cat.icon}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{cat.label}</span>
<small style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{stats?.category_counts[cat.key] ?? 0}件</small>
</button>
))}
</div>
{topUnworn.length > 0 && (
<>
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 10 }}>需要关注</h2>
{topUnworn.slice(0, 2).map((item) => (
<div key={item.id} className="card" style={{ marginBottom: 8, display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 20 }}>🔔</span>
<div style={{ flex: 1 }}>
<p style={{ fontSize: 14, fontWeight: 500 }}>{item.name}</p>
<small style={{ color: "var(--text-tertiary)" }}>超过30天未穿</small>
</div>
</div>
))}
</>
)}
</>
)}
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
import React, { useEffect, useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useAppStore } from "../stores/app";
import { APP_VERSION, BUILD_ID } from "../version";
import { checkOtaUpdate, reloadApp } from "../api/ota";
// R2 公共桶中的 APK 产物 (由 android-build.yml 上传, 固定覆盖式)
const APK_URL = "https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev/data/android/wardrobe.apk";
const FEATURES = [
{ icon: "👕", title: "衣物管理", desc: "分类归档 · 标签 · 季节/场合维度, 一目了然" },
{ icon: "✨", title: "智能搭配", desc: "根据风格与场合自动搭配, 解锁穿衣灵感" },
{ icon: "📅", title: "穿搭日历", desc: "每天记录穿搭与天气, 生成自己的风格轨迹" },
{ icon: "📊", title: "数据分析", desc: "统计利用率与偏好, 帮你理性管理衣橱" },
{ icon: "🔒", title: "隐私本地", desc: "数据存储在本地 DuckDB, 不上传云端" },
{ icon: "🤖", title: "MCP 支持", desc: "支持 MCP 协议, 可被 AI 助手直接调用" },
];
const STATS = [
{ num: "0.1.0", label: "当前版本" },
{ num: "100%", label: "本地离线" },
{ num: "Android", label: "移动端" },
];
function DownloadSection() {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(APK_URL);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<section style={styles.section}>
<h2 style={styles.sectionTitle}>下载 Android 版</h2>
<p style={styles.sectionSub}>Wardrobe 是离线优先的本地应用, 数据都保存在你的设备上。</p>
<div style={styles.downloadCard}>
<a className="btn btn-primary" href={APK_URL} download="wardrobe.apk" style={styles.downloadBtn}>
⬇️ 下载 APK
</a>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 10 }}>
版本 {APP_VERSION} · Android 8.0+ · 约 30 MB
</p>
<div style={{ marginTop: 16 }}>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: 6 }}>或复制下载链接(发给手机打开):</p>
<div style={styles.linkRow}>
<code style={styles.linkCode}>{APK_URL}</code>
<button className="btn btn-secondary" style={{ padding: "6px 12px", fontSize: 12 }} onClick={copy}>
{copied ? "已复制 ✓" : "复制"}
</button>
</div>
</div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 16, lineHeight: 1.6 }}>
安装提示: 首次安装需在系统设置中允许「安装未知来源应用」。
</p>
</div>
</section>
);
}
function WebUpdateBanner() {
const [update, setUpdate] = useState(false);
const [note, setNote] = useState("");
useEffect(() => {
checkOtaUpdate().then((r) => {
if (r.available && r.info) {
setUpdate(true);
setNote(r.info.notes || "");
}
});
}, []);
if (!update) return null;
return (
<div style={{ position: "fixed", bottom: 24, left: 0, right: 0, display: "flex", justifyContent: "center", zIndex: 50, padding: "0 16px" }}>
<div style={{ background: "#fff", border: "1px solid var(--border)", borderRadius: "var(--radius)", boxShadow: "0 8px 30px rgba(0,0,0,.12)", padding: "12px 16px", display: "flex", alignItems: "center", gap: 12, maxWidth: 520, width: "100%" }}>
<span style={{ fontSize: 18 }}>🆕</span>
<div style={{ flex: 1, fontSize: 13 }}>
<p style={{ fontWeight: 600 }}>有新版本可用{note ? `: ${note}` : ""}</p>
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>请刷新页面获取最新体验</p>
</div>
<button className="btn btn-primary" style={{ padding: "8px 16px", fontSize: 13 }} onClick={reloadApp}>
刷新
</button>
</div>
</div>
);
}
export default function Landing() {
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
return (
<div style={{ minHeight: "100vh", background: "var(--bg)", color: "var(--text-primary)", userSelect: "text" }}>
<header style={styles.header}>
<Link to="/" style={styles.logo}>
<span style={styles.logoEmoji}>👗</span>
<span style={{ fontWeight: 700, fontSize: 18 }}>Wardrobe</span>
</Link>
<button
className="btn btn-primary"
style={{ padding: "8px 18px", fontSize: 14 }}
onClick={() => (user ? navigate("/home") : navigate("/login"))}
>
{user ? "进入应用" : "登录"}
</button>
</header>
<div style={styles.container}>
<section style={styles.hero}>
<h1 style={styles.heroTitle}>
你的智能衣橱
<br />
<span style={{ color: "var(--accent)" }}>随身穿搭管家</span>
</h1>
<p style={styles.heroSub}>
Wardrobe 是一款离线优先的衣物管理应用。记录每一件衣物, 智能生成搭配建议,
用数据了解自己的穿搭风格。数据 100% 存在本地, 隐私无忧。
</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap", marginTop: 28 }}>
<a className="btn btn-primary" href={APK_URL} download="wardrobe.apk" style={styles.heroBtn}>
⬇️ 下载 Android APK
</a>
<button className="btn btn-secondary" style={styles.heroBtn} onClick={() => navigate("/login")}>
在线体验 →
</button>
</div>
</section>
<section style={styles.section}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 16 }}>
{FEATURES.map((f) => (
<div key={f.title} style={styles.featureCard}>
<span style={{ fontSize: 28 }}>{f.icon}</span>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: "10px 0 6px" }}>{f.title}</h3>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6 }}>{f.desc}</p>
</div>
))}
</div>
</section>
<section style={{ ...styles.section, textAlign: "center" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 16 }}>
{STATS.map((s) => (
<div key={s.label} style={styles.statCard}>
<p style={{ fontSize: 24, fontWeight: 700, color: "var(--accent)" }}>{s.num}</p>
<p style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 4 }}>{s.label}</p>
</div>
))}
</div>
</section>
<DownloadSection />
<section style={styles.section}>
<h2 style={styles.sectionTitle}>技术栈</h2>
<p style={styles.sectionSub}>跨平台桌面 + 移动端, AI 就绪。</p>
<div style={styles.techRow}>
{["Tauri 2", "React 18 + TypeScript", "DuckDB (本地数据)", "Rust", "MCP Protocol"].map((t) => (
<span key={t} className="tag-pill" style={{ fontSize: 13, padding: "8px 14px" }}>
{t}
</span>
))}
</div>
</section>
</div>
<footer style={styles.footer}>
<p>Wardrobe · 构建 {BUILD_ID} · 数据保存在本地设备, 我们看不到你的衣橱</p>
</footer>
<WebUpdateBanner />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
maxWidth: 960,
margin: "0 auto",
padding: "16px 20px",
},
logo: { display: "flex", alignItems: "center", gap: 8, textDecoration: "none", color: "var(--text-primary)" },
logoEmoji: { fontSize: 24 },
container: { maxWidth: 960, margin: "0 auto", padding: "0 20px 60px" },
hero: { textAlign: "center", padding: "64px 0 48px" },
heroTitle: { fontSize: 40, fontWeight: 800, lineHeight: 1.3, letterSpacing: -0.5 },
heroSub: { fontSize: 16, color: "var(--text-secondary)", lineHeight: 1.8, maxWidth: 560, margin: "18px auto 0" },
heroBtn: { padding: "12px 24px", fontSize: 15 },
section: { padding: "40px 0 12px" },
sectionTitle: { fontSize: 24, fontWeight: 700, textAlign: "center" },
sectionSub: { fontSize: 14, color: "var(--text-secondary)", textAlign: "center", margin: "8px 0 22px" },
featureCard: { background: "var(--bg-card)", border: "1px solid var(--border)", borderRadius: "var(--radius)", padding: 22, textAlign: "left" },
statCard: { background: "var(--bg-warm)", borderRadius: "var(--radius)", padding: "20px 12px" },
downloadCard: {
textAlign: "center",
background: "var(--bg-card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
padding: "32px 20px",
maxWidth: 480,
margin: "0 auto",
},
downloadBtn: { padding: "14px 32px", fontSize: 16 },
linkRow: { display: "flex", alignItems: "center", gap: 8, justifyContent: "center", flexWrap: "wrap" },
linkCode: { fontSize: 11, color: "var(--text-secondary)", wordBreak: "break-all" },
techRow: { display: "flex", gap: 10, justifyContent: "center", flexWrap: "wrap" },
footer: { textAlign: "center", padding: "24px 16px", fontSize: 12, color: "var(--text-tertiary)" },
};
+106
View File
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { sendCode, register, login } from "../api";
import { useAppStore } from "../stores/app";
import { useAuthStore } from "../stores/auth";
export default function Login() {
const navigate = useNavigate();
const { account, code, nickname, isRegister, loading, setAccount, setCode, setNickname, setMode, setLoading } = useAuthStore();
const setUser = useAppStore((s) => s.setUser);
const [status, setStatus] = useState("");
const isEmail = account.includes("@");
const canSendCode = account.length >= 6;
const handleSendCode = async () => {
setLoading(true);
setStatus("");
try {
const result = await sendCode(account);
setStatus(result);
} catch (e) {
setStatus(String(e));
} finally {
setLoading(false);
}
};
const handleSubmit = async () => {
setLoading(true);
setStatus("");
try {
const email = isEmail ? account : null;
const phone = isEmail ? null : account;
const result = isRegister
? await register(email, phone, code, nickname || "时尚达人")
: await login(email, phone, code);
if (result.success && result.user) {
setUser(result.user, result.token);
navigate("/home", { replace: true });
} else {
setStatus(result.message);
}
} catch (e) {
setStatus(String(e));
} finally {
setLoading(false);
}
};
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: "0 28px", maxWidth: 420, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 40 }}>
<div style={{ fontSize: 56, marginBottom: 8 }}>👕</div>
<h1 style={{ fontSize: 32, letterSpacing: -0.5 }}>Wardrobe</h1>
<p style={{ color: "var(--text-secondary)", fontSize: 14, marginTop: 4 }}>你的 AI 穿搭私人管家</p>
</div>
<div className="card" style={{ padding: 24 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
<button
style={{ flex: 1, padding: "10px 0", borderRadius: "var(--radius-sm)", fontWeight: 600, fontSize: 14, background: isRegister ? "transparent" : "var(--text-primary)", color: isRegister ? "var(--text-secondary)" : "#fff" }}
onClick={() => setMode("login")}
>
登录
</button>
<button
style={{ flex: 1, padding: "10px 0", borderRadius: "var(--radius-sm)", fontWeight: 600, fontSize: 14, background: isRegister ? "var(--text-primary)" : "transparent", color: isRegister ? "#fff" : "var(--text-secondary)" }}
onClick={() => setMode("register")}
>
注册
</button>
</div>
<div className="input-group">
<label>{isEmail ? "邮箱" : "手机号"}</label>
<input className="input" placeholder={isEmail ? "you@example.com" : "13800001234"} value={account} onChange={(e) => setAccount(e.target.value.trim())} />
</div>
{isRegister && (
<div className="input-group">
<label>昵称</label>
<input className="input" placeholder="你的昵称" value={nickname} onChange={(e) => setNickname(e.target.value)} />
</div>
)}
<div className="input-group">
<label>验证码</label>
<div className="code-row">
<input className="input" placeholder="验证码" maxLength={4} inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} />
<button className="code-btn" disabled={!canSendCode || loading} onClick={handleSendCode}>
获取验证码
</button>
</div>
</div>
{status && <p style={{ fontSize: 13, color: status.includes("成功") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>{status}</p>}
<button className="btn btn-primary" disabled={loading || !account || code.length < 4} onClick={handleSubmit}>
{loading ? "处理中..." : isRegister ? "注册" : "登录"}
</button>
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { listOutfits } from "../api";
import { useAppStore } from "../stores/app";
const SAMPLE_OUTFITS = [
{ name: "通勤首选", score: 92, tag: "通勤", items: ["👕", "👖", "🧥", "👟"], labels: ["白衬衫", "卡其裤", "风衣", "小白鞋"] },
{ name: "周末休闲", score: 87, tag: "休闲", items: ["👔", "👖", "👟"], labels: ["条纹T恤", "牛仔裤", "运动鞋"] },
{ name: "约会穿搭", score: 85, tag: "约会", items: ["👗", "👜", "👠"], labels: ["碎花裙", "手提包", "单鞋"] },
];
export default function Outfits() {
const user = useAppStore((s) => s.user);
const outfits = useAppStore((s) => s.outfits);
const setOutfits = useAppStore((s) => s.setOutfits);
const [occasion, setOccasion] = useState("通勤");
const [loading, setLoading] = useState(true);
const occasions = ["通勤", "休闲", "约会", "运动"];
useEffect(() => {
if (!user) return;
(async () => {
try {
const data = await listOutfits(user.id);
setOutfits(data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
})();
}, [user]);
const matches = SAMPLE_OUTFITS.filter((o) => o.tag === occasion);
return (
<div>
<div className="page-header">
<h1 className="page-title">搭配推荐</h1>
</div>
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 6, overflowX: "auto" }}>
{occasions.map((o) => (
<button
key={o}
className={"tag-pill" + (occasion === o ? " tag-pill-active" : "")}
style={{ flexShrink: 0 }}
onClick={() => setOccasion(o)}
>
{o}
</button>
))}
</div>
</div>
{loading ? (
<div style={{ padding: 60, textAlign: "center" }}>加载中...</div>
) : (
<div>
{matches.map((outfit, i) => (
<div key={i} className="card" style={{ marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<span className="tag-pill" style={{ background: "var(--accent-peach)", color: "#B8734A", fontWeight: 600 }}>{outfit.name}</span>
<span style={{ fontSize: 13, color: "var(--success)", fontWeight: 500 }}>匹配度 {outfit.score}%</span>
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
{outfit.items.map((icon, j) => (
<div key={j} style={{ flex: 1, aspectRatio: "1", background: "var(--bg-warm)", borderRadius: 8, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontSize: 24 }}>{icon}</span>
<small style={{ fontSize: 10, color: "var(--text-secondary)" }}>{outfit.labels[j]}</small>
</div>
))}
</div>
<div className="card" style={{ background: "var(--accent-light)", marginBottom: 12, padding: 12 }}>
<p style={{ fontSize: 12, fontWeight: 600, color: "#B8734A", marginBottom: 4 }}>🤖 AI 解析</p>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5 }}>
这套搭配适配{outfit.tag}场合,色彩和谐,符合春季气候。
{outfit.items.length >= 4 ? "风衣可应对早晚温差。" : "整体舒适耐看。"}
</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-primary" style={{ padding: "10px 0", fontSize: 13 }} onClick={() => window.alert("已保存搭配")}>保存搭配</button>
<button className="btn btn-secondary" style={{ padding: "10px 0", fontSize: 13 }}>查看详情</button>
</div>
</div>
))}
{outfits.length > 0 && (
<>
<h2 style={{ fontSize: 17, fontWeight: 600, margin: "20px 0 12px" }}>我的搭配库</h2>
{outfits.map((o) => (
<div key={o.id} className="card" style={{ marginBottom: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<p style={{ fontSize: 14, fontWeight: 500 }}>{o.name}</p>
<small style={{ fontSize: 12, color: "var(--text-tertiary)" }}>{o.occasion || "通用"}{o.match_score ? ` · 匹配度 ${o.match_score}%` : ""}</small>
</div>
<button style={{ fontSize: 13, color: "var(--text-secondary)", background: "none" }}>√ 已保存</button>
</div>
))}
</>
)}
</div>
)}
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAppStore } from "../stores/app";
import { applyOtaUpdate, checkOtaUpdate, getCurrentVersionInfo, getOtaVersionInfo, reloadApp, rollbackOta } from "../api/ota";
type OtaState = "idle" | "checking" | "available" | "none" | "downloading" | "ready" | "error";
const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
function OtaSection() {
const [state, setState] = useState<OtaState>("idle");
const [info, setInfo] = useState<{ version?: string; notes?: string; sequence?: number; bundle_size?: number } | null>(null);
const [active, setActive] = useState<{ version?: string } | null>(null);
const [progress, setProgress] = useState({ received: 0, total: 0 });
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
getOtaVersionInfo().then((v) => {
if (!cancelled && v) setActive(v as { version?: string });
});
return () => {
cancelled = true;
};
}, []);
const cur = getCurrentVersionInfo();
const statusText: Record<OtaState, string> = {
idle: "点击检查是否有新版本",
checking: "检查中...",
available: "",
none: "已是最新版本",
downloading: "下载中, 请稍候",
ready: "已下载, 即将重启",
error: "操作失败",
};
const onCheck = async () => {
setError("");
setState("checking");
try {
const r = await checkOtaUpdate();
if (r.available && r.info) {
setInfo(r.info);
setState("available");
} else {
setInfo(null);
setState("none");
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setState("error");
}
};
const onApply = async () => {
setError("");
setState("downloading");
setProgress({ received: 0, total: 0 });
try {
await applyOtaUpdate((received, total) => setProgress({ received, total }));
setState("ready");
setTimeout(reloadApp, 1000);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setState("error");
}
};
const onRollback = async () => {
if (!window.confirm("确认回滚到内置版本?")) return;
try {
await rollbackOta();
setTimeout(reloadApp, 800);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const downloading = state === "downloading";
const pct = downloading && progress.total > 0 ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0;
return (
<div className="card" style={{ padding: "16px", marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontSize: 15, fontWeight: 600 }}>检查更新 (OTA)</span>
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>v{cur.version} · {cur.build}</span>
</div>
{active?.version && (
<p style={{ fontSize: 12, color: "var(--text-secondary)", marginBottom: 8 }}>
当前生效: v{active.version} (OTA 覆盖)
</p>
)}
{state === "available" && info && (
<div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 12, lineHeight: 1.6 }}>
<p>
发现新版本 <b style={{ color: "var(--text-primary)" }}>v{info.version}</b>
{info.bundle_size ? ` (${(info.bundle_size / 1024).toFixed(0)} KB)` : ""}
</p>
{info.notes && <p style={{ marginTop: 4 }}>发布说明: {info.notes}</p>}
</div>
)}
{downloading && (
<div style={{ marginBottom: 12 }}>
<div style={{ height: 6, borderRadius: 3, background: "var(--bg-warm)", overflow: "hidden" }}>
<div style={{ height: "100%", width: `${pct}%`, background: "var(--accent)", transition: "width .2s" }} />
</div>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 6 }}>
{progress.total ? `${(progress.received / 1024).toFixed(0)} / ${(progress.total / 1024).toFixed(0)} KB (${pct}%)` : "连接中..."}
</p>
</div>
)}
{(state === "none" || state === "idle") && (
<p style={{ fontSize: 13, color: "var(--text-tertiary)", marginBottom: 12 }}>{statusText[state]}</p>
)}
{state === "error" && error && (
<p style={{ fontSize: 13, color: "var(--danger)", marginBottom: 12, wordBreak: "break-all" }}>{error}</p>
)}
<div style={{ display: "flex", gap: 10 }}>
<button className="btn btn-primary" style={{ flex: 1, padding: "10px 0", fontSize: 13 }} onClick={onCheck} disabled={downloading}>
{state === "checking" ? "检查中..." : "检查更新"}
</button>
{state === "available" && (
<button className="btn btn-secondary" style={{ flex: 1, padding: "10px 0", fontSize: 13 }} onClick={onApply}>
立即升级
</button>
)}
{isTauri && active?.version && (
<button className="btn btn-outline" style={{ flex: 1, padding: "10px 0", fontSize: 13 }} onClick={onRollback}>
回滚
</button>
)}
</div>
</div>
);
}
export default function Profile() {
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const stats = useAppStore((s) => s.stats);
const logout = useAppStore((s) => s.logout);
const handleLogout = () => {
logout();
localStorage.removeItem("wardrobe_user");
navigate("/login", { replace: true });
};
if (!user) return null;
const menuItems = [
{ icon: "📅", label: "穿搭日历", onClick: () => navigate("/calendar") },
{ icon: "❤️", label: "我的收藏" },
{ icon: "🧺", label: "清洁管理" },
{ icon: "📊", label: "风格统计" },
{ icon: "☁️", label: "云端同步" },
{ icon: "📤", label: "导出数据" },
{ icon: "⚙️", label: "设置" },
{ icon: "❓", label: "帮助与反馈" },
];
return (
<div>
<div className="page-header">
<h1 className="page-title">我的</h1>
</div>
<div style={{ textAlign: "center", padding: "12px 0 24px" }}>
<div style={{ width: 72, height: 72, borderRadius: "50%", background: "var(--bg-warm)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 34, margin: "0 auto 10px" }}>
👤
</div>
<h2 style={{ fontSize: 20, fontWeight: 700 }}>{user.nickname}</h2>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: 2 }}>
{user.email || user.phone}
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 1, background: "var(--border)", borderRadius: "var(--radius-sm)", overflow: "hidden", marginBottom: 24 }}>
{[
{ num: stats?.total_items ?? 0, label: "衣物" },
{ num: stats?.total_outfits ?? 0, label: "搭配" },
{ num: `${stats?.utilization_rate ?? 0}%`, label: "利用率" },
{ num: "28", label: "连续天" },
].map((s, i) => (
<div key={i} style={{ background: "#fff", padding: "14px 4px", textAlign: "center" }}>
<p style={{ fontSize: 18, fontWeight: 700 }}>{s.num}</p>
<p style={{ fontSize: 11, color: "var(--text-secondary)", marginTop: 2 }}>{s.label}</p>
</div>
))}
</div>
<OtaSection />
<div className="card" style={{ padding: 0, marginBottom: 16 }}>
{menuItems.map((m, i) => (
<button
key={m.label}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
background: "none",
borderBottom: i < menuItems.length - 1 ? "1px solid var(--border)" : "none",
textAlign: "left",
}}
onClick={m.onClick}
>
<span style={{ fontSize: 20, width: 28, textAlign: "center" }}>{m.icon}</span>
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{m.label}</span>
<span style={{ color: "var(--text-tertiary)" }}>›</span>
</button>
))}
</div>
<button className="btn btn-outline" style={{ color: "var(--danger)" }} onClick={handleLogout}>
退出登录
</button>
<div style={{ height: 24 }} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { create } from "zustand";
import type { User, ClothingItem, Outfit } from "../types";
import type { DailyRecord, WardrobeStats } from "../api";
interface AppState {
user: User | null;
token: string | null;
clothing: ClothingItem[];
outfits: Outfit[];
dailyRecords: DailyRecord[];
stats: WardrobeStats | null;
setUser: (user: User | null, token?: string | null) => void;
setClothing: (items: ClothingItem[]) => void;
addClothingItem: (item: ClothingItem) => void;
setOutfits: (outfits: Outfit[]) => void;
setDailyRecords: (records: DailyRecord[]) => void;
setStats: (stats: WardrobeStats) => void;
logout: () => void;
}
export const useAppStore = create<AppState>((set) => ({
user: null,
token: null,
clothing: [],
outfits: [],
dailyRecords: [],
stats: null,
setUser: (user, token = null) => set({ user, token }),
setClothing: (clothing) => set({ clothing }),
addClothingItem: (item) => set((s) => ({ clothing: [item, ...s.clothing] })),
setOutfits: (outfits) => set({ outfits }),
setDailyRecords: (dailyRecords) => set({ dailyRecords }),
setStats: (stats) => set({ stats }),
logout: () => set({ user: null, token: null, clothing: [], outfits: [], dailyRecords: [], stats: null }),
}));
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
interface AuthState {
account: string;
isEmail: boolean;
code: string;
nickname: string;
isRegister: boolean;
loading: boolean;
setAccount: (v: string) => void;
setCode: (v: string) => void;
setNickname: (v: string) => void;
setMode: (m: "login" | "register") => void;
setLoading: (b: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
account: "",
isEmail: true,
code: "",
nickname: "",
isRegister: false,
loading: false,
setAccount: (account) => set({ account, isEmail: account.includes("@") }),
setCode: (code) => set({ code }),
setNickname: (nickname) => set({ nickname }),
setMode: (m) => set({ isRegister: m === "register" }),
setLoading: (loading) => set({ loading }),
}));
+273
View File
@@ -0,0 +1,273 @@
:root {
--bg: #FCF9F4;
--bg-card: #FFFFFF;
--bg-warm: #F5EDE6;
--text-primary: #1A1A1A;
--text-secondary: #666666;
--text-tertiary: #999999;
--accent: #E8C4A0;
--accent-light: #FFF6ED;
--accent-peach: #FFE8E0;
--border: #F0E8DF;
--danger: #E74C3C;
--success: #27AE60;
--radius: 16px;
--radius-sm: 10px;
--radius-xs: 6px;
--nav-height: 68px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
user-select: none;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
}
input,
select,
textarea {
font-family: inherit;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
max-width: 500px;
margin: 0 auto;
}
.app-content {
flex: 1;
overflow-y: auto;
padding: 8px 20px 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 16px;
position: sticky;
top: 0;
background: var(--bg);
z-index: 10;
}
.page-title {
font-size: 26px;
font-weight: 700;
}
/* Bottom Nav */
.bottom-nav {
display: flex;
height: var(--nav-height);
background: var(--bg-card);
border-top: 1px solid var(--border);
padding: 6px 8px;
padding-bottom: calc(6px + env(safe-area-inset-bottom));
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
background: none;
color: var(--text-tertiary);
font-size: 11px;
transition: color 0.2s;
}
.nav-item.active {
color: var(--text-primary);
font-weight: 600;
}
.nav-item .nav-icon {
font-size: 20px;
}
.center-nav-btn {
position: relative;
top: -14px;
}
.center-nav-btn .fab-circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* Buttons */
.btn {
padding: 12px 20px;
border-radius: var(--radius-sm);
font-size: 15px;
font-weight: 600;
transition: opacity 0.15s, transform 0.15s;
}
.btn:active {
opacity: 0.85;
transform: scale(0.98);
}
.btn-primary {
background: var(--text-primary);
color: #fff;
width: 100%;
}
.btn-secondary {
background: var(--bg-warm);
color: var(--text-primary);
width: 100%;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
width: 100%;
}
.btn-danger {
background: var(--danger);
color: #fff;
}
/* Cards */
.card {
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
padding: 16px;
}
/* Forms */
.input-group {
margin-bottom: 16px;
}
.input-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.input {
width: 100%;
padding: 13px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.input:focus {
border-color: var(--accent);
}
.code-row {
display: flex;
gap: 10px;
}
.code-row .input {
flex: 1;
}
.code-btn {
padding: 13px 18px;
background: var(--accent-light);
color: #B8734A;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.code-btn:disabled {
opacity: 0.5;
}
/* Tag Pills */
.tag-pill {
display: inline-block;
padding: 5px 14px;
border-radius: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.tag-pill-active {
background: var(--text-primary);
border-color: var(--text-primary);
color: #fff;
}
/* Toast */
.toast {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 10px 24px;
border-radius: 20px;
font-size: 14px;
z-index: 1000;
animation: toast-in 0.3s;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
+57
View File
@@ -0,0 +1,57 @@
export interface ClothingItem {
id: string;
user_id: string;
name: string;
category: string;
color: string;
brand?: string;
material?: string;
seasons: string[];
occasions: string[];
formality: number;
thickness: number;
image_path?: string;
purchase_date?: string;
purchase_price?: number;
last_cleaned?: string;
clean_method?: string;
wear_count: number;
last_worn?: string;
is_favorite: boolean;
created_at: string;
updated_at: string;
}
export interface Outfit {
id: string;
user_id: string;
name: string;
item_ids: string[];
occasion?: string;
season?: string;
match_score?: number;
ai_analysis?: string;
is_saved: boolean;
created_at: string;
}
export interface User {
id: string;
email?: string;
phone?: string;
nickname: string;
avatar_path?: string;
created_at: string;
}
export const CATEGORIES = [
{ key: "top", label: "上衣", icon: "👕" },
{ key: "bottom", label: "下装", icon: "👖" },
{ key: "outer", label: "外套", icon: "🧥" },
{ key: "dress", label: "连衣裙", icon: "👗" },
{ key: "shoes", label: "鞋履", icon: "👟" },
{ key: "accessory", label: "配饰", icon: "👜" },
] as const;
export const SEASONS = ["春", "夏", "秋", "冬"] as const;
export const OCCASIONS = ["通勤", "休闲", "运动", "约会", "正式"] as const;
+9
View File
@@ -0,0 +1,9 @@
// 应用版本信息唯一汇聚点
// - APP_VERSION: 唯一手动管理点 = package.json 的 version 字段 (发版时改那里)
// - BUILD_ID: 每次编译自动生成 (vite.config.ts define 注入 __BUILD_ID__), 无需手动维护
export const APP_VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.0.0";
// __BUILD_ID__ 机制:
// - build 时: vite:define 编译期替换为字符串字面量
// - dev 时: vite 通过 /@vite/env 把值挂到 globalThis, 源码保持原样;
// typeof 防御避免 /@vite/env 未执行时 ReferenceError, 回退 'dev'
export const BUILD_ID = typeof __BUILD_ID__ !== "undefined" ? __BUILD_ID__ : "dev";
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
export {}
declare global {
// vite.config.ts 里 define 注入的编译期常量
// - __BUILD_ID__: 每次构建自动生成 (YYYYMMDD.HHmmss), OTA/web 升级比对用
// - __APP_VERSION__: 从 package.json 读取注入
const __BUILD_ID__: string
const __APP_VERSION__: string
}