refactor: 数据层迁往服务端 (Express+DuckDB), APP 变瘦客户端只留缓存
- server: 新增 DuckDB 数据层 (db.js) 与鉴权 (auth.js: 验证码+token), 挂载 /api 业务路由 (衣物/搭配/日历/统计/MCP), 错误统一中间件 - 前端 src/api/index.ts 全部业务调用改为 HTTP (Bearer token), 客户端不再持有数据库 - 删除 Rust 数据层 (db.rs/auth.rs/mcp.rs/models.rs), Cargo.toml 裁剪 duckdb/chrono 等依赖 - Dockerfile: 基镜像 alpine→slim (@duckdb/node-api glibc 预编译), DB 落 /app/data 卷 - docker-build.yml: 移除 prune --volumes, 挂具名卷 wardrobe-data 持久化 - Landing/Profile 文案改为云端同步口径; 退出登录调服务端会话销毁
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// 统一 API 基址: 生产同源 (/api), 也可用 VITE_API_BASE 强制绝对地址
|
||||
export const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "";
|
||||
+88
-28
@@ -1,4 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
// 数据层统一走 HTTP API (服务端 DuckDB 是唯一数据源)
|
||||
// - 客户端不再持有数据库, 仅保留最近数据的读缓存 (localStorage wardrobe_user 快照)
|
||||
// - 登录态: localStorage 中的 token, 通过 Authorization: Bearer 携带
|
||||
// - Web 与 Tauri 客户端共用同一套接口, web 端因此完整可用
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export interface ClothingItem {
|
||||
id: string;
|
||||
@@ -80,43 +84,99 @@ export interface McpTool {
|
||||
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 });
|
||||
function base(p: string) {
|
||||
return API_BASE ? API_BASE + p : p;
|
||||
}
|
||||
|
||||
// token 从登录快照取 (RouterGate 会持久化整个 store 到 wardrobe_user)
|
||||
function getToken(): string | null {
|
||||
try {
|
||||
const saved = localStorage.getItem("wardrobe_user");
|
||||
if (!saved) return null;
|
||||
const p = JSON.parse(saved);
|
||||
return p?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(base(path), {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new Error("网络错误,请检查网络连接");
|
||||
}
|
||||
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* 无 body */
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = (data as { error?: string; message?: string } | null)?.error
|
||||
?? (data as { message?: string } | null)?.message
|
||||
?? `请求失败 (${res.status})`;
|
||||
if (res.status === 401) localStorage.removeItem("wardrobe_user");
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ── Auth ──
|
||||
export const sendCode = (target: string) =>
|
||||
request<{ message: string }>("POST", "/api/auth/send-code", { target }).then((r) => r.message);
|
||||
export const register = (email: string | null, phone: string | null, code: string, nickname: string) =>
|
||||
request<AuthResult>("POST", "/api/auth/register", { email, phone, code, nickname });
|
||||
export const login = (email: string | null, phone: string | null, code: string) =>
|
||||
request<AuthResult>("POST", "/api/auth/login", { email, phone, code });
|
||||
export const logout = () =>
|
||||
request<{ success: boolean }>("POST", "/api/auth/logout").then(() => undefined);
|
||||
export const fetchMe = () =>
|
||||
request<{ user: User }>("GET", "/api/auth/me").then((r) => r.user);
|
||||
|
||||
// ── Clothing (userId 仅作兼容参数, 服务端以登录态为准) ──
|
||||
export const addClothing = (_userId: string, name: string, category: string, color: string, brand?: string, seasons?: string[], occasions?: string[]) =>
|
||||
request<ClothingItem>("POST", "/api/clothing", { name, category, color, brand, seasons, occasions });
|
||||
export const listClothing = (_userId: string, category?: string) =>
|
||||
request<ClothingItem[]>("GET", `/api/clothing${category ? `?category=${encodeURIComponent(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 });
|
||||
request<ClothingItem | null>("GET", `/api/clothing/${encodeURIComponent(id)}`);
|
||||
export const updateClothing = (_userId: string, id: string, name: string, category: string, color: string) =>
|
||||
request<void>("PUT", `/api/clothing/${encodeURIComponent(id)}`, { name, category, color });
|
||||
export const deleteClothing = (id: string) =>
|
||||
invoke<void>("delete_clothing", { id });
|
||||
request<void>("DELETE", `/api/clothing/${encodeURIComponent(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 });
|
||||
export const addOutfit = (_userId: string, name: string, itemIds: string[], occasion?: string, season?: string) =>
|
||||
request<Outfit>("POST", "/api/outfits", { name, item_ids: itemIds, occasion, season });
|
||||
export const listOutfits = (_userId: string) =>
|
||||
request<Outfit[]>("GET", "/api/outfits");
|
||||
|
||||
// ── 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 });
|
||||
export const recordDaily = (_userId: string, date: string, outfitId?: string, notes?: string) =>
|
||||
request<DailyRecord>("POST", "/api/daily", { date, outfit_id: outfitId, notes });
|
||||
export const listDailyRecords = (_userId: string, limit?: number) =>
|
||||
request<DailyRecord[]>("GET", `/api/daily${limit ? `?limit=${limit}` : ""}`);
|
||||
|
||||
// ── Stats ──
|
||||
export const getStats = (userId: string) =>
|
||||
invoke<WardrobeStats>("get_stats", { userId });
|
||||
export const getStats = (_userId: string) =>
|
||||
request<WardrobeStats>("GET", "/api/stats");
|
||||
|
||||
// ── MCP ──
|
||||
export const mcpListTools = () =>
|
||||
invoke<McpTool[]>("mcp_list_tools");
|
||||
request<McpTool[]>("GET", "/api/mcp/tools");
|
||||
export const mcpCallTool = (toolName: string, args: Record<string, unknown>) =>
|
||||
invoke<unknown>("mcp_call_tool", { toolName, args });
|
||||
request<unknown>("POST", "/api/mcp/call", { tool_name: toolName, args });
|
||||
+3
-2
@@ -10,10 +10,11 @@ import { APP_VERSION, BUILD_ID } from "../version";
|
||||
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
|
||||
export const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
// 拼接绝对 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;
|
||||
return API_BASE ? API_BASE + p : p;
|
||||
}
|
||||
|
||||
export interface OtaUpdateInfo {
|
||||
|
||||
@@ -6,17 +6,17 @@ import { APP_VERSION, BUILD_ID } from "../version";
|
||||
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: "统计利用率与偏好, 帮你理性管理衣橱" },
|
||||
{ icon: "🔒", title: "隐私本地", desc: "数据存储在本地 DuckDB, 不上传云端" },
|
||||
{ icon: "☁️", title: "云端同步", desc: "数据存储于服务端, 登录账号多端同步, 换机不丢" },
|
||||
{ icon: "🤖", title: "MCP 支持", desc: "支持 MCP 协议, 可被 AI 助手直接调用" },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ num: "0.1.0", label: "当前版本" },
|
||||
{ num: "100%", label: "本地离线" },
|
||||
{ num: "多端", label: "云端同步" },
|
||||
{ num: "Android", label: "移动端" },
|
||||
];
|
||||
|
||||
@@ -24,7 +24,7 @@ function DownloadSection() {
|
||||
return (
|
||||
<section style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>下载 Android 版</h2>
|
||||
<p style={styles.sectionSub}>Wardrobe 是离线优先的本地应用, 数据都保存在你的设备上。</p>
|
||||
<p style={styles.sectionSub}>登录账号后数据自动云端同步, 换设备也不丢。</p>
|
||||
<div style={styles.downloadCard}>
|
||||
<a className="btn btn-primary" href={APK_URL} download="wardrobe.apk" style={styles.downloadBtn}>
|
||||
⬇️ 下载 APK
|
||||
@@ -64,8 +64,8 @@ export default function Landing() {
|
||||
<span style={{ color: "var(--accent)" }}>随身穿搭管家</span>
|
||||
</h1>
|
||||
<p style={styles.heroSub}>
|
||||
Wardrobe 是一款离线优先的衣物管理应用。记录每一件衣物, 智能生成搭配建议,
|
||||
用数据了解自己的穿搭风格。数据 100% 存在本地, 隐私无忧。
|
||||
Wardrobe 是一款衣物管理应用。记录每一件衣物, 智能生成搭配建议,
|
||||
用数据了解自己的穿搭风格。数据云端同步, 登录即用。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function Landing() {
|
||||
<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) => (
|
||||
{["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>
|
||||
@@ -108,7 +108,7 @@ export default function Landing() {
|
||||
</div>
|
||||
|
||||
<footer style={styles.footer}>
|
||||
<p>Wardrobe · 构建 {BUILD_ID} · 数据保存在本地设备, 我们看不到你的衣橱</p>
|
||||
<p>Wardrobe · 构建 {BUILD_ID} · 数据存储于服务端, 请保管好你的账号</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { logout as logoutApi } from "../api";
|
||||
import { applyOtaUpdate, checkOtaUpdate, getCurrentVersionInfo, getOtaVersionInfo, reloadApp, rollbackOta } from "../api/ota";
|
||||
|
||||
type OtaState = "idle" | "checking" | "available" | "none" | "downloading" | "ready" | "error";
|
||||
@@ -147,6 +148,7 @@ export default function Profile() {
|
||||
const logout = useAppStore((s) => s.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutApi().catch(() => { /* 服务端会话已废, 本地照常登出 */ });
|
||||
logout();
|
||||
localStorage.removeItem("wardrobe_user");
|
||||
navigate("/login", { replace: true });
|
||||
|
||||
Reference in New Issue
Block a user