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
+205
View File
@@ -0,0 +1,205 @@
name: Android Build & Publish
# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0)
# 构建 release APK (keystore 签名) 并上传到 Cloudflare R2 (S3 兼容 API)
# R2 凭据从配置注册中心 (bh.vps.honor3.com:8008, CommonExternalService, 分组 cloudflare) 读取
# 参考: D:\workbench\iboard\.gitea\workflows\android-build.yml
on:
push:
branches:
- 'android-*'
jobs:
build-android:
runs-on: ubuntu-latest
permissions:
contents: read
env:
# 固定工具链版本, 便于 actions/cache 命中
NDK_VERSION: '27.2.12479018'
# 配置中心地址与 token (Gitea secrets; URL 未设时回退默认地址)
CONFIG_CENTER_URL: '${{ secrets.CONFIG_CENTER_URL }}'
CONFIG_CENTER_TOKEN: '${{ secrets.CONFIG_CENTER_TOKEN }}'
REGISTRY_URL_FALLBACK: 'http://bh.vps.honor3.com:8008'
# 配置中心项目 ID: 签名密钥在 service-wardrobe/signing, R2 在 CommonExternalService/cloudflare
CFG_PROJECT_SIGNING: 'proj-mtwjnvir-k52zvp'
CFG_PROJECT_R2: 'proj-mts0s5jo-fb3rz7'
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Setup mise
uses: https://gitea.com/actions/mise@v2
with:
tools: "node:24.21.0,rust:stable"
cache: true
- name: Setup JDK 17
uses: https://gitea.com/actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
# ---------- 尽量复用 Android SDK ----------
# 优先复用 runner 预装的 SDK (/usr/local/lib/android/sdk), 其次命中 CI 缓存,
# 最后才重新下载。NDK 不在预装中, 单独用缓存复用。
- name: Locate reusable Android SDK
id: sdk
run: |
CANDIDATES=(
"$ANDROID_HOME"
"/usr/local/lib/android/sdk" # GitHub ubuntu-latest 预装
"$HOME/android-sdk"
)
for c in "${CANDIDATES[@]}"; do
if [ -n "$c" ] && [ -d "$c/platforms" ]; then
echo "ANDROID_ROOT=${c}" >> "$GITHUB_ENV"
echo "found=${c}" >> "$GITHUB_OUTPUT"
echo "reusing existing Android SDK: ${c}"
break
fi
done
echo "found=${found:-}"
- name: Cache Android NDK
id: cache-ndk
uses: https://gitea.com/actions/cache@v4
with:
path: ~/android-sdk/ndk
key: android-ndk-${{ env.NDK_VERSION }}-${{ runner.os }}
- name: Cache Rust toolchain
id: cache-rust
uses: https://gitea.com/actions/cache@v4
with:
path: |
~/.mise
~/.local/share/mise
key: mise-rust-android-v1-${{ runner.os }}
- name: Cache Tauri cargo target
uses: https://gitea.com/actions/cache@v4
with:
path: src-tauri/target
key: tauri-target-android-${{ runner.os }}
# ---------- 安装缺失组件 (仅未复用/未命中时) ----------
- name: Ensure Android NDK
if: steps.sdk.outputs.found == '' || steps.cache-ndk.outputs.cache-hit != 'true'
run: |
if [ -z "${ANDROID_ROOT:-}" ]; then
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
sudo apt-get update
sudo apt-get install -y --no-install-recommends unzip
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip
unzip -q /tmp/cmdline-tools.zip -d /tmp/
mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null || true
sdkmanager --install \
"platforms;android-36" \
"build-tools;36.0.0" \
"platform-tools"
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
fi
# NDK (cache miss 或首页 SDK 未含 NDK 时安装到 ~/android-sdk/ndk)
if [ ! -d "$HOME/android-sdk/ndk/${{ env.NDK_VERSION }}" ]; then
export ANDROID_HOME="${ANDROID_ROOT:-$HOME/android-sdk}"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
fi
- name: Add Android Rust targets
run: |
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
cargo install cargo-ndk --locked || true
- name: Install frontend deps
run: npm ci --no-audit --no-fund
# ---------- 全局 Android 环境 ----------
- name: Set Android env
run: |
echo "ANDROID_HOME=${ANDROID_ROOT:-$HOME/android-sdk}" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=${ANDROID_ROOT:-$HOME/android-sdk}/ndk/${{ env.NDK_VERSION }}" >> "$GITHUB_ENV"
echo "${ANDROID_ROOT:-$HOME/android-sdk}/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
echo "${ANDROID_ROOT:-$HOME/android-sdk}/platform-tools" >> "$GITHUB_PATH"
# ---------- 构建 ----------
- name: Init Android project
run: |
cd src-tauri
npx tauri android init --ci
- name: Fetch config center (signing + R2)
run: |
set -euo pipefail
export CONFIG_CENTER_URL="${CONFIG_CENTER_URL:-$REGISTRY_URL_FALLBACK}"
python3 - <<'PYEOF'
import json, os, urllib.request
url = os.environ["CONFIG_CENTER_URL"]
token = os.environ.get("CONFIG_CENTER_TOKEN", "") or ""
wanted_keys = {
"CFG_PROJECT_SIGNING": ("ANDROID_KEYSTORE_B64", "ANDROID_KEY_ALIAS", "ANDROID_KEY_PASSWORD"),
"CFG_PROJECT_R2": ("CF_API_TOKEN", "CF_R2_PUBLIC_URL"),
}
wanted = {}
for pid_env, keys in wanted_keys.items():
pid = os.environ[pid_env]
req = urllib.request.Request(f"{url}/api/projects/{pid}/configs")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=20) as r:
data = json.load(r).get("data", [])
for cfg in data:
if cfg.get("key") in keys:
wanted[cfg["key"]] = cfg.get("value", "")
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as f:
for k, v in wanted.items():
f.write(f"{k}={v}\n")
missing = [k for k in ("ANDROID_KEYSTORE_B64", "ANDROID_KEY_ALIAS", "ANDROID_KEY_PASSWORD", "CF_API_TOKEN") if k not in wanted]
if missing:
raise SystemExit("配置中心缺少密钥: " + ", ".join(missing))
PYEOF
- name: Inject release signing config
run: bash scripts/ci/android-signing.sh
- name: Build release APK
run: |
export ANDROID_HOME="${ANDROID_ROOT:-$HOME/android-sdk}"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/${{ env.NDK_VERSION }}"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
cd src-tauri
npx tauri android build --apk
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
exit 1
fi
- name: Locate APK
id: apk
run: |
APK=""
UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1)
if [ -n "$UNIVERSAL" ]; then
APK="$UNIVERSAL"
else
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1)
fi
if [ -z "$APK" ]; then
echo "ERROR: 未找到已签名 APK" >&2
exit 1
fi
echo "path=$APK" >> "$GITHUB_OUTPUT"
ls -lh "$APK"
- name: Upload APK to Cloudflare R2
env:
APK_PATH: ${{ steps.apk.outputs.path }}
run: |
bash scripts/upload-r2.sh "$APK_PATH"
+112
View File
@@ -0,0 +1,112 @@
name: OTA Frontend Build & Sign
# 手动触发 (发版前用): 构建前端 → 打生产 dist 的 OTA 升级包 (minisign 签名) → 上传 R2
# 产物:
# - ota-package/wardrobe-ota.tar.gz (前端替换包)
# - ota-package/latest.json (manifest, 客户端 check 用)
# 存储: Cloudflare R2 store/ota/ (公开桶)
# 依赖配置注册中心 (bh.vps.honor3.com:8008):
# - service-wardrobe / ota : MINISIGN_PRIVATE_KEY (整段 .key 内容, 多行)
# - CommonExternalService / cloudflare : CF_API_TOKEN, CF_R2_PUBLIC_URL
# 注意: 不修改 src-tauri/tauri.conf.json 的 pubkey, 换密钥请手动改 conf 并 commit。
# 部署 server (托管 OTA check 端点 + 首页 dist) 见 README "OTA 升级 与 首页" 章节。
on:
workflow_dispatch:
inputs:
sequence:
description: 'OTA sequence (单调递增整数, 留空用 timestamp)'
required: false
type: string
notes:
description: '发布说明 (写入 latest.json.notes)'
required: false
type: string
default: ''
jobs:
build-ota:
runs-on: ubuntu-latest
permissions:
contents: read
env:
CONFIG_CENTER_URL: '${{ secrets.CONFIG_CENTER_URL }}'
CONFIG_CENTER_TOKEN: '${{ secrets.CONFIG_CENTER_TOKEN }}'
REGISTRY_URL_FALLBACK: 'http://bh.vps.honor3.com:8008'
CFG_PROJECT_SIGNING: 'proj-mtwjnvir-k52zvp'
CFG_PROJECT_R2: 'proj-mts0s5jo-fb3rz7'
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Install deps
run: npm ci --no-audit --no-fund
- name: Build frontend
# 复用 vite build 产物 (同时是 OTA bundle 内容)
run: npm run build
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Fetch config center (minisign key + R2)
run: |
set -euo pipefail
export CONFIG_CENTER_URL="${CONFIG_CENTER_URL:-$REGISTRY_URL_FALLBACK}"
python3 - <<'PYEOF'
import json, os, urllib.request
url = os.environ["CONFIG_CENTER_URL"]
token = os.environ.get("CONFIG_CENTER_TOKEN", "") or ""
wanted = {}
req = urllib.request.Request(f"{url}/api/projects/{os.environ['CFG_PROJECT_SIGNING']}/configs")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=20) as r:
data = json.load(r).get("data", [])
for cfg in data:
if cfg.get("key") in ("MINISIGN_PRIVATE_KEY",):
wanted[cfg["key"]] = cfg.get("value", "")
req = urllib.request.Request(f"{url}/api/projects/{os.environ['CFG_PROJECT_R2']}/configs")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=20) as r:
data = json.load(r).get("data", [])
for cfg in data:
if cfg.get("key") in ("CF_API_TOKEN", "CF_R2_PUBLIC_URL"):
wanted[cfg["key"]] = cfg.get("value", "")
# GITHUB_ENV 多行值需用 heredoc 语法; 单行值直接 NAME=value
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as f:
for k, v in wanted.items():
if "\n" in v or v.startswith("untrusted comment"):
f.write(f"{k}<<EOF\n{v}\nEOF\n")
else:
f.write(f"{k}={v}\n")
missing = [k for k in ("MINISIGN_PRIVATE_KEY", "CF_API_TOKEN") if k not in wanted]
if missing:
raise SystemExit("配置中心缺少: " + ", ".join(missing))
PYEOF
- name: Build & sign OTA package
# MINISIGN_PRIVATE_KEY 由上一步写入 GITHUB_ENV, 已是本步的真实环境变量,
# build-ota.mjs 直接读 process.env.MINISIGN_PRIVATE_KEY, 无需再映射
env:
OTA_NOTES: ${{ inputs.notes }}
run: |
set -euo pipefail
ARGS="--out ota-package"
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
node scripts/build-ota.mjs ${ARGS}
echo "===== latest.json ====="
cat ota-package/latest.json
echo "===== bundle sha256 ====="
sha256sum ota-package/wardrobe-ota.tar.gz
- name: Upload OTA package to Cloudflare R2
run: |
bash scripts/upload-ota-r2.sh ota-package
+27
View File
@@ -0,0 +1,27 @@
node_modules
dist
dist-ssr
*.local
src-tauri/target
.DS_Store
# Tauri 生成的 Android 工程 (由 `tauri android init` 生成, 不入库)
src-tauri/gen
# Android 构建输出
src-tauri/gen/android/app/build
# 签名密钥与配置 (绝不能提交)
*.keystore
keystore.properties
src-tauri/gen/android/keystore.properties
# 本地环境变量
.env
.env.*
!.env.example
# OTA 升级产物与私钥 (全部由 scripts/build-ota.mjs、ota-genkey.mjs 与 CI 生成)
ota-package/
# wrangler 本地缓存
.wrangler
+7
View File
@@ -0,0 +1,7 @@
[tools]
node = "24.21.0"
npm = "12.0.2"
rust = "stable"
# 每个项目在 cd 到仓库目录后自动加载以上工具链 (需 mise activate)
# 全局配置: ~/.config/mise/config.toml
+217
View File
@@ -0,0 +1,217 @@
# Wardrobe 衣物管理系统 - 设计规范
## 1. 项目概述
基于 Tauri 框架封装的 Android 衣物管理应用,帮助用户管理衣橱、获取 AI 穿搭推荐、记录每日穿搭。
## 2. 竞品分析
### 2.1 截图分析结果
- **屏幕尺寸**: 1080×2400 (Android 标准 20:9)
- **主色调**: 暖白/奶油色 (#FCF9F4, #FBF8F3)
- **点缀色**: 蜜桃色/粉色 (#FFE8E0, #FFF6ED)
- **设计风格**: 简约、温暖、卡片式布局
### 2.2 竞品功能对比
| 功能 | Acloset | J衣橱 | WearWow | 爱搭衣橱 | 本项目 |
|------|---------|--------|---------|----------|--------|
| 数字衣橱 | ✅ | ✅ | ✅ | ✅ | ✅ |
| AI 识别 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 搭配推荐 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 穿搭日历 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 虚拟试衣 | ✅ | ❌ | ✅ | ✅ | ❌ |
| 清洁管理 | ❌ | ✅ | ❌ | ✅ | ✅ |
| 旅行规划 | ✅ | ❌ | ❌ | ❌ | ❌ |
| 风格统计 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 本地优先 | ❌ | ✅ | ❌ | ❌ | ✅ |
## 3. 设计系统
### 3.1 色彩
```
主背景: #FCF9F4 (暖白)
卡片背景: #FFFFFF
暖色背景: #F5EDE6
边框色: #F0E8DF
主文字: #1A1A1A
次文字: #666666
辅助文字: #999999
强调色: #E8C4A0 (金色)
点缀色: #FFE8E0 (蜜桃)
警告色: #E74C3C
成功色: #27AE60
```
### 3.2 字体
- 标题: 28px, 700 weight
- 副标题: 18px, 600 weight
- 正文: 15px, 400 weight
- 辅助: 12-13px, 400 weight
- 系统字体: -apple-system, PingFang SC, Microsoft YaHei
### 3.3 圆角
- 大卡片: 16px
- 中卡片: 10px
- 小元素: 6px
- 标签/徽章: 20px (全圆角)
### 3.4 间距
- 页面边距: 20px
- 卡片内边距: 16px
- 元素间距: 8-12px
- 区块间距: 20-24px
## 4. 页面结构
### 4.1 底部导航 (5个Tab)
| Tab | 图标 | 页面 | 功能 |
|-----|------|------|------|
| 首页 | 🏠 | page-home | 今日推荐、概览统计、提醒 |
| 搭配 | ❤️ | page-outfit | AI 搭配推荐、场景筛选 |
| 添加 | ➕ | page-add | 拍照/上传、AI 识别、表单 |
| 衣橱 | 👜 | page-closet | 衣物列表、搜索筛选 |
| 我的 | 👤 | page-profile | 个人中心、设置 |
### 4.2 首页 (Home)
- 天气卡片 (温度、季节、场合建议)
- 今日推荐搭配 (大卡片、4件单品预览)
- 衣橱概览 (总数、搭配数、利用率)
- 分类网格 (6类: 上衣/下装/外套/连衣裙/鞋履/配饰)
- 提醒列表 (未穿、待清洁)
### 4.3 衣橱 (Closet)
- 搜索栏
- 分类筛选 (品类 + 季节 + 颜色)
- 视图切换 (网格/列表)
- 衣物卡片 (图片 + 名称 + 穿着频率)
### 4.4 添加衣物 (Add)
- 照片上传区 (拍照/相册)
- AI 识别提示
- 基础信息表单 (品类、名称、颜色、品牌)
- 属性标签 (季节、场合、正式度、厚度)
- 购买信息 (日期、价格)
### 4.5 衣物详情 (Detail)
- 大图展示
- 标签信息
- 穿着统计 (本月/总次数/拥有天数)
- 基本信息
- 清洁记录
- 搭配历史
### 4.6 搭配推荐 (Outfit)
- 季节/场合筛选
- 搭配方案卡片 (匹配度、单品列表)
- AI 解析说明
- 保存/详情操作
### 4.7 穿搭日历 (Calendar)
- 月历视图 (标记有搭配的日期)
- 当日搭配详情
- 历史穿搭列表
### 4.8 个人中心 (Profile)
- 用户信息
- 统计数据 (衣物/搭配/利用率/连续天数)
- 功能菜单 (日历/收藏/清洁/统计/同步/导出/设置)
## 5. 技术架构
### 5.1 Tauri + Android
```
wardrobe-app/
├── src-tauri/ # Tauri 后端 (Rust)
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ └── src/
│ └── main.rs
├── src/ # 前端 (React/Vue/Svelte)
│ ├── components/
│ ├── pages/
│ ├── stores/
│ └── App.tsx
├── android/ # Android 原生层
│ └── app/
└── package.json
```
### 5.2 数据模型
```typescript
interface ClothingItem {
id: string;
name: string;
category: 'top' | 'bottom' | 'outer' | 'dress' | 'shoes' | 'accessory';
color: string;
brand?: string;
material?: string;
seasons: string[];
occasions: string[];
formality: number; // 0-100
thickness: number; // 0-100
imageUrl: string;
purchaseDate?: string;
purchasePrice?: number;
lastCleaned?: string;
cleanMethod?: string;
wearCount: number;
lastWorn?: string;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
interface Outfit {
id: string;
name: string;
items: string[]; // ClothingItem IDs
occasion: string;
season: string;
matchScore: number; // 0-100
aiAnalysis?: string;
isSaved: boolean;
createdAt: string;
}
interface DailyRecord {
date: string;
outfitId?: string;
weather?: {
temp: number;
condition: string;
};
notes?: string;
}
```
## 6. 原型文件
原型位于 `prototype/` 目录:
- `index.html` - 主页面结构
- `style.css` - 样式定义
- `script.js` - 交互逻辑
打开方式: 直接在浏览器中打开 `prototype/index.html`
## 7. 下一步
1. 搭建 Tauri 项目骨架
2. 实现前端页面 (React + TailwindCSS)
3. 集成本地存储 (SQLite)
4. 实现 AI 识别功能 (相机 + 图像识别)
5. 构建 Android APK
+128
View File
@@ -0,0 +1,128 @@
# Wardrobe MCP 服务说明
MCP (Model Context Protocol) 服务提供衣物管理平台数据管理接口,供 AI 助手、外部系统进行数据 CRUD 操作。
## 架构
```
┌─────────────┐ IPC (Tauri invoke) ┌──────────────┐
│ Web 前端 │ ───────────────────────▶ │ Rc MCP Service│
└─────────────┘ └──────┬───────┘
│
┌───────▼───────┐
│ DuckDB │
│ wardrobe.duckdb│
└───────────────┘
```
MCP 服务通过 Tauri 的 `mcp_list_tools` / `mcp_call_tool` 命令暴露,前端或其他 Tauri 插件可通过 `invoke` 调用。
## 可用工具列表
### 衣物管理
| 工具 | 描述 | 必需参数 |
|------|------|----------|
| `list_clothing` | 获取用户衣橱衣物列表 | `user_id`, 可选 `category` |
| `get_clothing` | 获取单品详情 | `id` |
| `add_clothing` | 添加衣物 | `user_id`, `name`, `category`, `color` |
| `update_clothing` | 更新衣物 | `id`, 可选 `name`/`category`/`color` |
| `delete_clothing` | 删除衣物 | `id` |
### 搭配管理
| 工具 | 描述 | 必需参数 |
|------|------|----------|
| `list_outfits` | 获取搭配列表 | `user_id` |
| `add_outfit` | 创建搭配方案 | `user_id`, `name`, `item_ids[]` |
### 穿搭记录
| 工具 | 描述 | 必需参数 |
|------|------|----------|
| `record_daily_outfit` | 记录每日穿搭 | `user_id`, `date` (YYYY-MM-DD) |
| `list_daily_records` | 获取穿搭日历 | `user_id`, 可选 `limit` |
### 统计
| 工具 | 描述 | 必需参数 |
|------|------|----------|
| `get_wardrobe_stats` | 衣橱统计数据 | `user_id` |
## 调用示例
### 列出用户衣物
```json
{
"tool_name": "list_clothing",
"args": { "user_id": "uuid-xxx", "category": "top" }
}
```
### 添加衣物
```json
{
"tool_name": "add_clothing",
"args": {
"user_id": "uuid-xxx",
"name": "白色衬衫",
"category": "top",
"color": "#FFFFFF",
"brand": "优衣库",
"seasons": ["春", "夏"],
"occasions": ["通勤"]
}
}
```
### 获取统计
```json
{
"tool_name": "get_wardrobe_stats",
"args": { "user_id": "uuid-xxx" }
}
```
返回:
```json
{
"total_items": 42,
"total_outfits": 15,
"utilization_rate": 73.0,
"category_counts": { "top": 12, "bottom": 8, "outer": 6, "dress": 5, "shoes": 6, "accessory": 5 },
"favorite_count": 3,
"unworn_30d": 2,
"needs_cleaning": 2
}
```
## 整合到 AI 客户端配置
在 AI 客户端 (如 Claude, Cursor) 的 MCP 配置中添加:
```json
{
"mcpServers": {
"wardrobe": {
"command": "npx",
"args": ["-y", "@wardrobe/mcp-server"],
"env": { "WARDROBE_DB_PATH": "path/to/wardrobe.duckdb" }
}
}
}
```
> 注: 当前版本 MCP 通过 Tauri IPC 暴露,AI 客户端集成需将 `McpService` 包装为标准 MCP stdio server。`src-tauri/src/mcp.rs` 中 `list_tools` / `call_tool` 已实现会话无关的纯逻辑接口,可直接复用。
## 认证
验证码暂统一为 `1111`。注册/登录流程:
1. `send_code(target)` - 发送验证码 (target 为邮箱或手机号)
2. `register(email|null, phone|null, code, nickname)` - 注册
3. `login(email|null, phone|null, code)` - 登录
验证码有效期 5 分钟,成功后自动失效。
+264
View File
@@ -0,0 +1,264 @@
# Wardrobe - AI 衣物管理系统
基于 **Tauri 2.x** 封装的跨平台衣物管理应用,目标平台包括 **Android / Windows / macOS**。
## 功能特性
- 📸 **数字衣橱** - 拍照录入、AI 识别、分类管理
- 💡 **AI 搭配推荐** - 按季节/场合智能推荐
- 📅 **穿搭日历** - 记录每日 OOTD
- 📊 **风格统计** - 衣橱利用率、分类分析
- 🧺 **清洁管理** - 清洁周期提醒
- 🔐 **邮箱/手机注册** - 验证码认证 (暂定 `1111`)
- 🗄️ **MCP 服务** - AI 平台数据管理接口
- 🦆 **DuckDB** - 本地嵌入式数据库
## 技术栈
| 层 | 技术 |
|----|------|
| 前端 | React 18 + TypeScript + Vite + Zustand |
| 桌面壳 | Tauri 2 (WebView) |
| 后端 | Rust + DuckDB (bundled) |
| 移动端 | Tauri Android |
| 状态管理 | Zustand |
| 路由 | React Router v6 |
## 目录结构
```
├── src/ # React 前端
│ ├── api/ # Tauri IPC 封装
│ ├── components/ # 通用组件 (Layout)
│ ├── pages/ # 页面
│ │ ├── Landing.tsx # 项目首页 (响应式, 含 APK 下载)
│ │ ├── Login.tsx # 登录/注册 (邮箱/手机 + 验证码)
│ │ ├── Home.tsx # 首页/今日推荐
│ │ ├── Closet.tsx # 衣橱列表
│ │ ├── AddClothing.tsx # 添加衣物
│ │ ├── ClothingDetail.tsx
│ │ ├── Outfits.tsx # 搭配推荐
│ │ ├── Calendar.tsx # 穿搭日历
│ │ └── Profile.tsx # 我的 (含 OTA 升级区)
│ ├── api/
│ │ ├── index.ts # Tauri IPC 封装
│ │ └── ota.ts # OTA 封装 (Tauri + Web 双路径)
│ ├── version.ts # 版本/构建号 (vite define 注入)
│ ├── stores/ # Zustand 状态
│ ├── styles/ # 全局样式
│ └── types/ # TS 类型
├── server/ # OTA 服务端 (Express)
│ ├── index.js # 静态 dist + SPA fallback + /api/health
│ └── routes/ota.js # OTA check/bundle/manifest-web 接口
├── scripts/
│ ├── build-ota.mjs # OTA 打包 + minisign 签名 + latest.json
│ ├── ota-genkey.mjs # 生成 minisign 密钥对
│ ├── upload-ota-r2.sh # OTA 包上传 R2
│ └── ... # Android 构建/签名/上传脚本
├── src-tauri/ # Rust 后端
│ ├── src/
│ │ ├── main.rs # 桌面入口 (薄壳)
│ │ ├── lib.rs # 应用逻辑 + mobile_entry_point + hotswap 插件
│ │ ├── db.rs # DuckDB 数据层
│ │ ├── models.rs # 数据模型
│ │ ├── auth.rs # 认证 (验证码 1111)
│ │ └── mcp.rs # MCP 服务
│ ├── Cargo.toml
│ ├── tauri.conf.json # 含 plugins.hotswap (OTA 端点 + 公钥)
│ └── capabilities/default.json
├── prototype/ # HTML 交互原型
└── photograph/ # 竞品截图
```
## 快速开始
### 前置要求
- [Node.js](https://nodejs.org) ≥ 18
- [Rust](https://rustup.rs) ≥ 1.77
- [Android Studio](https://developer.android.com/studio) (Android 构建)
### 开发
```bash
npm install
npm run tauri dev # 桌面端开发
```
### Android 构建
```bash
npm run tauri android init # 首次生成 Android 工程
npm run tauri android dev # Android 设备调试
npm run tauri android build # 生成 APK
```
## 关键设计
### 数据库 (DuckDB)
- 数据文件: `%APP_DATA%/wardrobe/wardrobe.duckdb`, Android 下存于应用私有目录
- 表: `users`, `clothing_items`, `outfits`, `daily_records`
- 读写均通过 `Mutex<Connection>` 串行化,保证线程安全
### 优雅停机
- `ctrlc` 信号捕获 → 设置 shutdown flag
- 窗口关闭事件 → 记录日志、释放连接
- DuckDB 连接在进程结束时由 OS 正常关刷
### 验证码 (临时)
- 统一固定为 `1111`
- 有效期 5 分钟,成功后自失效
- 注册: 邮箱 或 手机号 (二选一) + `1111`
### MCP 服务
详见 [MCP.md](MCP.md)。通过 `mcp_list_tools` / `mcp_call_tool` IPC 命令暴露 10 个数据管理工具。
## Android CI/CD
参考 [D:\workbench\iboard](../iboard) 的 Gitea Actions 部署模式。
### 触发方式
推送 `android-*` 分支 (如 `android/release`) 自动触发构建。
### 流程 (`.gitea/workflows/android-build.yml`)
```
push android-* → checkout → mise( node/rust ) → JDK 17
→ 定位可复用 Android SDK (优先 runner 预装 /usr/local/lib/android/sdk, 其次 CI 缓存, 最后下载)
→ 缓存复用 NDK 27.2 / Rust 工具链 / Tauri target
→ tauri android init --ci
→ 从配置中心拉取全部密钥 (签名 + R2)
→ scripts/ci/android-signing.sh 注入签名
→ tauri android build --apk → 断言签名生效
→ scripts/upload-r2.sh 用 wrangler 上传 APK 到 R2 公开桶 (store/data/android/wardrobe.apk)
→ 输出公共下载地址 (https://pub-...r2.dev/data/android/wardrobe.apk)
```
### CI secrets (仅配置中心连接)
| Secret | 说明 |
|--------|------|
| `CONFIG_CENTER_URL` | 配置中心地址 (未设时回退 `http://bh.vps.honor3.com:8008`) |
| `CONFIG_CENTER_TOKEN` | 配置中心访问 token |
所有业务密钥 (签名 keystore / R2 token) 一律来自配置中心,不进 Gitea secrets。
### 配置中心存储约定
| 项目 | 分组 | key | 用途 |
|------|------|-----|------|
| `service-wardrobe` | `signing` | `ANDROID_KEYSTORE_B64` | keystore base64 (JKS RSA 2048) |
| `service-wardrobe` | `signing` | `ANDROID_KEY_ALIAS` | `wardrobe` |
| `service-wardrobe` | `signing` | `ANDROID_KEY_PASSWORD` | store/key 密码 |
| `service-wardrobe` | `ota` | `MINISIGN_PRIVATE_KEY` | OTA 包 minisign 私钥 (整段 .key, 多行) |
| `service-wardrobe` | `ota` | `CF_OTA_ENDPOINT` | OTA 检查端点 (部署参考) |
| `CommonExternalService` | `cloudflare` | `r2/CF_API_TOKEN` | Cloudflare API token (wrangler 上传) |
| `CommonExternalService` | `cloudflare` | `r2/CF_R2_PUBLIC_URL` | R2 公共访问域名 |
CI 的 `Fetch config center` 步骤拉取以上全部 key 注入环境变量,签名与上传步骤均不再依赖 Gitea secrets。
建议用 `CONFIG_CENTER_TOKEN` 为配置中心加鉴权(当前 REST API 无鉴权,任何人可读/写)。
### 本地构建 (可选, WSL)
```bash
bash scripts/build-apk.sh debug # 或 release
# 脚本自动复用本机 Android SDK/NDK (~/android-sdk, NDK 27.2.x)
```
## 项目首页 (Landing)
`/` 为公开的项目首页 (`src/pages/Landing.tsx`),响应式适配桌面与手机 web:
- 项目简介 + 功能特性 + 技术栈
- **APK 下载**: 按钮直链 R2 公共桶 `https://pub-...r2.dev/data/android/wardrobe.apk`(由 android-build.yml 上传),附版本号、复制链接与安装提示
- web 新版本提示: 启动时请求 `/api/ota/manifest-web`,有新构建号时底部浮出"刷新"横幅
- 登录后的应用首页迁移到 `/home`(底部导航同步更新)
部署: `npm run build && OTA_PACKAGE_DIR=ota-package npm start`,server 同时托管首页 dist 与 OTA 接口。
## OTA 升级
参考 [iboard](../iboard),采用 **tauri-plugin-hotswap** 前端热替换方案(minisign 签名 + 下载 + 校验 + 原子替换),升级不经过应用市场。
### 原理
- **Tauri 客户端**: 启动时 `notifyReady()`;检查更新走插件内置 `checkUpdate()`,下载/校验/替换/回滚均由插件完成(公钥在 `src-tauri/tauri.conf.json` → `plugins.hotswap.pubkey`,端点在 `plugins.hotswap.endpoint`)
- **Web 浏览器**: 只比对 `/api/ota/manifest-web` 的 `build_id` 与当前 `BUILD_ID`,提示刷新(Vite 指纹会缓存失效)
- 构建号 `__BUILD_ID__`(YYYYMMDD.HHmmss)由 `vite.config.ts` 每次构建注入
### 服务端 (`server/`)
| 接口 | 说明 |
|------|------|
| `GET /api/ota/check/:seq` | 有新包 → 200 + manifest;无 → 204 |
| `GET /api/ota/bundle/:file` | 下载 tar.gz 升级包 (流式) |
| `GET /api/ota/manifest-web` | Web 简化版 manifest |
| `GET /api/health` | 健康检查 |
数据源: 环境变量 `OTA_PACKAGE_DIR`(默认 `ota-package/`)下的 `latest.json` + `wardrobe-ota.tar.gz`。
`PORT` 默认 `3002`(与 iboard 的 3001 同机共存)。可选 `OTA_PUBLIC_BASE` 让客户端直连 R2 下载 bundle。
### 本地生成 OTA 包
```bash
npm run build # 先构建 dist
minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key # 或 npm run ota:genkey
npm run build:ota -- --notes "v0.2 更新说明" # 打包 + 签名 + 写 latest.json
npm start # 起服务, 手动验收 /api/ota/*
```
首次需安装 [minisign](https://jedisct1.github.io/minisign/)(Windows: scoop/choco 或官方 win64 二进制)。
生成的 `hotswap.key` 请存到配置中心 `service-wardrobe / ota / MINISIGN_PRIVATE_KEY`(不要提交 git),
`hotswap.pub` 的公钥行写入 `src-tauri/tauri.conf.json`。
### OTA 发布 CI (`.gitea/workflows/ota-build.yml`)
手动 `workflow_dispatch` 触发:
```
build frontend → npm run build
→ apt install minisign
→ 从配置中心拉取 MINISIGN_PRIVATE_KEY (service-wardrobe/ota) + CF_API_TOKEN (cloudflare)
→ node scripts/build-ota.mjs 打包+签名
→ scripts/upload-ota-r2.sh 上传到 R2 (store/ota/wardrobe-ota.tar.gz + latest.json)
```
### 客户端升级操作
「我的」页 → 「检查更新 (OTA)」:
- 检查更新 / 立即升级(带进度)/ 回滚(仅 OTA 覆盖时显示)
- Web 端仅可检查新构建号提示刷新
## mise 本地工具链
本项目用 [mise](https://mise.jdx.dev) 统一管理 Node/npm/Rust 版本,仓库根目录 `.mise.toml` 声明工具版本,`cd` 进入目录后自动激活。
```toml
[tools]
node = "24.21.0"
npm = "12.0.2"
rust = "stable"
```
### Windows 安装 (已完成)
1. 下载 `mise-v*-windows-x64.zip` → 解压到 `~\.local\bin\mise.exe`
2. 用户 PATH 追加: `~\.local\bin` 与 `~\AppData\Local\mise\shims`
3. PowerShell `$PROFILE` 添加: `& "$env:USERPROFILE\.local\bin\mise.exe" activate powershell`
4. `mise use -g node@24.21.0 npm@12.0.2 rust@stable` 安装全局工具
> 注意: Windows 下 mise 数据目录在 `~\AppData\Local\mise`,shims 位于 `~\AppData\Local\mise\shims` (非 `~\.local\share\mise\shims`)。
## 原型
浏览器打开 `prototype/index.html` 查看可交互原型。
## License
Private
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Wardrobe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4211
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "wardrobe",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"android:dev": "tauri android dev",
"android:build": "tauri android build",
"check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"dev:server": "node --watch --experimental-strip-types --no-warnings server/index.js",
"start": "node --experimental-strip-types --no-warnings server/index.js",
"build:ota": "node scripts/build-ota.mjs",
"ota:genkey": "node scripts/ota-genkey.mjs"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"tauri-plugin-hotswap-api": "^0.0.1",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.14.0",
"@typescript-eslint/parser": "^7.14.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"vite-plugin-singlefile": "^2.0.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

+822
View File
@@ -0,0 +1,822 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=390, initial-scale=1.0">
<title>Wardrobe - 衣物管理系统 原型设计</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="phone-frame">
<div class="status-bar">
<span class="time">9:41</span>
<div class="status-icons">
<svg width="17" height="12" viewBox="0 0 17 12"><rect x="0" y="3" width="3" height="9" rx="1" fill="#1a1a1a"/><rect x="4.5" y="2" width="3" height="10" rx="1" fill="#1a1a1a"/><rect x="9" y="0" width="3" height="12" rx="1" fill="#1a1a1a"/><rect x="13.5" y="1" width="3" height="11" rx="1" fill="#1a1a1a"/></svg>
<svg width="25" height="12" viewBox="0 0 25 12"><rect x="0" y="0.5" width="22" height="11" rx="2" stroke="#1a1a1a" fill="none" stroke-width="1"/><rect x="22.5" y="3.5" width="2" height="5" rx="1" fill="#1a1a1a"/><rect x="1.5" y="2" width="19" height="8" rx="1" fill="#1a1a1a"/></svg>
</div>
</div>
<!-- Page: Home -->
<div class="page active" id="page-home">
<div class="page-header">
<div>
<p class="greeting">早上好 ☀️</p>
<h1 class="page-title">今日穿搭</h1>
</div>
<div class="header-actions">
<button class="icon-btn" onclick="showPage('page-add')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
</div>
<div class="scroll-content">
<div class="weather-card">
<div class="weather-info">
<span class="weather-icon">🌤️</span>
<div>
<p class="weather-temp">24°C</p>
<p class="weather-desc">多云 · 适合轻薄外套</p>
</div>
</div>
<div class="weather-tags">
<span class="tag">春</span>
<span class="tag">通勤</span>
</div>
</div>
<div class="section-header">
<h2>推荐搭配</h2>
<button class="text-btn" onclick="showPage('page-outfit')">查看更多</button>
</div>
<div class="outfit-card">
<div class="outfit-preview">
<div class="outfit-item-grid">
<div class="outfit-thumb" style="background:#E8DDD3;">
<span>👕</span><small>白衬衫</small>
</div>
<div class="outfit-thumb" style="background:#D4C5B5;">
<span>👖</span><small>卡其裤</small>
</div>
<div class="outfit-thumb" style="background:#C9B8A5;">
<span>🧥</span><small>米色风衣</small>
</div>
<div class="outfit-thumb" style="background:#E0D5C8;">
<span>👟</span><small>小白鞋</small>
</div>
</div>
</div>
<div class="outfit-info">
<div class="outfit-tags">
<span class="occasion-tag">通勤</span>
<span class="rating">⭐ 4.8</span>
</div>
<p class="outfit-label">简约通勤风</p>
<div class="outfit-actions">
<button class="action-btn save-btn" onclick="showToast('已保存到日历')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
保存到日历
</button>
<button class="action-btn refresh-btn" onclick="refreshOutfit()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
换一套
</button>
</div>
</div>
</div>
<div class="section-header">
<h2>衣橱概览</h2>
<button class="text-btn" onclick="showPage('page-closet')">查看全部</button>
</div>
<div class="stats-row">
<div class="stat-card">
<span class="stat-num">42</span>
<span class="stat-label">衣物总数</span>
</div>
<div class="stat-card">
<span class="stat-num">15</span>
<span class="stat-label">搭配收藏</span>
</div>
<div class="stat-card">
<span class="stat-num">73%</span>
<span class="stat-label">利用率</span>
</div>
</div>
<div class="category-grid">
<div class="category-item" onclick="showPage('page-closet')">
<div class="category-icon" style="background:#F5EDE6;">👕</div>
<span>上衣</span>
<small>12件</small>
</div>
<div class="category-item" onclick="showPage('page-closet')">
<div class="category-icon" style="background:#EDE5DA;">👖</div>
<span>下装</span>
<small>8件</small>
</div>
<div class="category-item" onclick="showPage('page-closet')">
<div class="category-icon" style="background:#E5DDD4;">🧥</div>
<span>外套</span>
<small>6件</small>
</div>
<div class="category-item" onclick="showPage('page-closet')">
<div class="category-icon" style="background:#DDD5CC;">👗</div>
<span>连衣裙</span>
<small>5件</small>
</div>
<div class="category-item" onclick="showPage('page-closet')">
<div class="category-icon" style="background:#D8D0C7;">👟</div>
<span>鞋履</span>
<small>6件</small>
</div>
<div class="category-item" onclick="showPage('page-closet')">
<div class="category-icon" style="background:#F0E8DF;">👜</div>
<span>配饰</span>
<small>5件</small>
</div>
</div>
<div class="section-header">
<h2>需要关注</h2>
</div>
<div class="alert-list">
<div class="alert-item">
<span class="alert-icon">🔔</span>
<div class="alert-text">
<p>3件衣物超过30天未穿</p>
<small>考虑搭配或整理</small>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="alert-item">
<span class="alert-icon">🧺</span>
<div class="alert-text">
<p>2件衣物待清洁</p>
<small>白衬衫、运动T恤</small>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
<!-- Page: Closet -->
<div class="page" id="page-closet">
<div class="page-header">
<h1 class="page-title">我的衣橱</h1>
<button class="icon-btn" onclick="showPage('page-add')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="scroll-content">
<div class="search-bar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="搜索衣物..." class="search-input">
</div>
<div class="filter-bar" id="filter-bar">
<button class="filter-btn active" onclick="filterCloset(this, 'all')">全部</button>
<button class="filter-btn" onclick="filterCloset(this, 'top')">上衣</button>
<button class="filter-btn" onclick="filterCloset(this, 'bottom')">下装</button>
<button class="filter-btn" onclick="filterCloset(this, 'outer')">外套</button>
<button class="filter-btn" onclick="filterCloset(this, 'dress')">连衣裙</button>
<button class="filter-btn" onclick="filterCloset(this, 'shoes')">鞋履</button>
<button class="filter-btn" onclick="filterCloset(this, 'acc')">配饰</button>
</div>
<div class="filter-bar" style="margin-top:8px;">
<button class="filter-btn small" onclick="this.classList.toggle('active')">春</button>
<button class="filter-btn small" onclick="this.classList.toggle('active')">夏</button>
<button class="filter-btn small" onclick="this.classList.toggle('active')">秋</button>
<button class="filter-btn small" onclick="this.classList.toggle('active')">冬</button>
<span class="filter-sep">|</span>
<button class="filter-btn small" onclick="this.classList.toggle('active')">黑</button>
<button class="filter-btn small" onclick="this.classList.toggle('active')">白</button>
<button class="filter-btn small" onclick="this.classList.toggle('active')">蓝</button>
<button class="filter-btn small" onclick="this.classList.toggle('active')">棕</button>
</div>
<div class="view-toggle">
<button class="view-btn active" id="grid-view-btn" onclick="setView('grid')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
</button>
<button class="view-btn" id="list-view-btn" onclick="setView('list')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="4" width="18" height="4" rx="1"/><rect x="3" y="10" width="18" height="4" rx="1"/><rect x="3" y="16" width="18" height="4" rx="1"/></svg>
</button>
</div>
<div class="closet-grid" id="closet-grid">
<div class="clothing-card" data-cat="top" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#F0E8DF;"><span>👕</span></div>
<div class="clothing-meta">
<p class="clothing-name">白色衬衫</p>
<small>上衣 · 3次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="bottom" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#E5DDD4;"><span>👖</span></div>
<div class="clothing-meta">
<p class="clothing-name">卡其休闲裤</p>
<small>下装 · 5次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="outer" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#DDD5CC;"><span>🧥</span></div>
<div class="clothing-meta">
<p class="clothing-name">米色风衣</p>
<small>外套 · 2次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="dress" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#F5EDE6;"><span>👗</span></div>
<div class="clothing-meta">
<p class="clothing-name">碎花连衣裙</p>
<small>连衣裙 · 1次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="shoes" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#E8E0D7;"><span>👟</span></div>
<div class="clothing-meta">
<p class="clothing-name">白色运动鞋</p>
<small>鞋履 · 8次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="acc" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#F0E8DF;"><span>👜</span></div>
<div class="clothing-meta">
<p class="clothing-name">棕色手提包</p>
<small>配饰 · 4次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="top" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#E0D8CF;"><span>👔</span></div>
<div class="clothing-meta">
<p class="clothing-name">条纹T恤</p>
<small>上衣 · 6次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="bottom" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#D8D0C7;"><span>👖</span></div>
<div class="clothing-meta">
<p class="clothing-name">蓝色牛仔裤</p>
<small>下装 · 7次/月</small>
</div>
</div>
<div class="clothing-card" data-cat="outer" onclick="showPage('page-detail')">
<div class="clothing-img" style="background:#C9C1B8;"><span>🧶</span></div>
<div class="clothing-meta">
<p class="clothing-name">灰色针织衫</p>
<small>外套 · 3次/月</small>
</div>
</div>
</div>
<div class="closet-list hidden" id="closet-list">
<div class="list-item" onclick="showPage('page-detail')">
<div class="list-thumb" style="background:#F0E8DF;">👕</div>
<div class="list-info">
<p>白色衬衫</p>
<small>上衣 · 白色 · 春夏 · 购于2025-03</small>
</div>
<span class="list-freq">3次/月</span>
</div>
<div class="list-item" onclick="showPage('page-detail')">
<div class="list-thumb" style="background:#E5DDD4;">👖</div>
<div class="list-info">
<p>卡其休闲裤</p>
<small>下装 · 棕色 · 四季 · 购于2025-01</small>
</div>
<span class="list-freq">5次/月</span>
</div>
<div class="list-item" onclick="showPage('page-detail')">
<div class="list-thumb" style="background:#DDD5CC;">🧥</div>
<div class="list-info">
<p>米色风衣</p>
<small>外套 · 米色 · 春秋 · 购于2024-10</small>
</div>
<span class="list-freq">2次/月</span>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
<!-- Page: Add Clothing -->
<div class="page" id="page-add">
<div class="page-header">
<button class="icon-btn" onclick="showPage('page-closet')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<h1 class="page-title center-title">添加衣物</h1>
<button class="text-btn save-text" onclick="showToast('已保存');showPage('page-closet')">保存</button>
</div>
<div class="scroll-content">
<div class="photo-upload-area" onclick="simulateUpload()">
<div class="upload-placeholder" id="upload-placeholder">
<div class="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ccc" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
</div>
<p>点击拍照或从相册选择</p>
<small>支持 AI 自动识别衣物信息</small>
</div>
<div class="upload-preview hidden" id="upload-preview">
<div class="preview-placeholder">
<span style="font-size:64px;">👕</span>
<small>已选择图片</small>
</div>
<button class="retake-btn" onclick="event.stopPropagation();resetUpload()">重新选择</button>
</div>
</div>
<div class="ai-badge">
<span>🤖</span> AI 将自动识别品类、颜色、材质
</div>
<div class="form-section">
<h3>基础信息</h3>
<div class="form-group">
<label>品类</label>
<select class="form-select">
<option>请选择品类</option>
<option>上衣</option>
<option>下装</option>
<option>外套</option>
<option>连衣裙</option>
<option>鞋履</option>
<option>配饰</option>
</select>
</div>
<div class="form-group">
<label>名称</label>
<input type="text" class="form-input" placeholder="如:白色衬衫">
</div>
<div class="form-group">
<label>颜色</label>
<div class="color-picker">
<span class="color-dot" style="background:#1a1a1a;" onclick="selectColor(this)"></span>
<span class="color-dot" style="background:#f5f5f5;border:1px solid #ddd;" onclick="selectColor(this)"></span>
<span class="color-dot" style="background:#4a6fa5;" onclick="selectColor(this)"></span>
<span class="color-dot" style="background:#8b7355;" onclick="selectColor(this)"></span>
<span class="color-dot" style="background:#c0392b;" onclick="selectColor(this)"></span>
<span class="color-dot" style="background:#27ae60;" onclick="selectColor(this)"></span>
<span class="color-dot" style="background:#f39c12;" onclick="selectColor(this)"></span>
<span class="color-dot" style="background:#8e44ad;" onclick="selectColor(this)"></span>
<span class="color-dot active" style="background:#ecdsa0;" onclick="selectColor(this)"></span>
</div>
</div>
<div class="form-group">
<label>品牌</label>
<input type="text" class="form-input" placeholder="如:ZARA">
</div>
</div>
<div class="form-section">
<h3>属性标签</h3>
<div class="tag-select">
<span class="selectable-tag active">春</span>
<span class="selectable-tag">夏</span>
<span class="selectable-tag active">秋</span>
<span class="selectable-tag">冬</span>
</div>
<div class="tag-select">
<span class="selectable-tag active">通勤</span>
<span class="selectable-tag">休闲</span>
<span class="selectable-tag">运动</span>
<span class="selectable-tag">约会</span>
<span class="selectable-tag">正式</span>
</div>
<div class="form-group">
<label>正式度</label>
<input type="range" class="form-range" min="0" max="100" value="60">
<div class="range-labels"><span>休闲</span><span>正式</span></div>
</div>
<div class="form-group">
<label>厚度</label>
<input type="range" class="form-range" min="0" max="100" value="30">
<div class="range-labels"><span>薄</span><span>厚</span></div>
</div>
</div>
<div class="form-section">
<h3>购买信息</h3>
<div class="form-row">
<div class="form-group half">
<label>购买日期</label>
<input type="date" class="form-input">
</div>
<div class="form-group half">
<label>购买价格</label>
<input type="number" class="form-input" placeholder="¥">
</div>
</div>
</div>
<button class="primary-btn" onclick="showToast('已保存');showPage('page-closet')">保存到衣橱</button>
<div class="bottom-spacer"></div>
</div>
</div>
<!-- Page: Clothing Detail -->
<div class="page" id="page-detail">
<div class="page-header">
<button class="icon-btn" onclick="showPage('page-closet')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<div class="header-actions">
<button class="icon-btn" onclick="showToast('已编辑')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="icon-btn danger" onclick="confirmDelete()">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
<div class="scroll-content">
<div class="detail-hero">
<div class="detail-img" style="background:#F0E8DF;">
<span style="font-size:80px;">👕</span>
</div>
</div>
<div class="detail-info-card">
<h2>白色衬衫</h2>
<div class="detail-tags">
<span class="detail-tag">上衣</span>
<span class="detail-tag">白色</span>
<span class="detail-tag">春</span>
<span class="detail-tag">夏</span>
<span class="detail-tag">通勤</span>
</div>
</div>
<div class="detail-stats">
<div class="detail-stat">
<span class="detail-stat-num">3</span>
<span class="detail-stat-label">本月穿着</span>
</div>
<div class="detail-stat">
<span class="detail-stat-num">18</span>
<span class="detail-stat-label">总穿着次数</span>
</div>
<div class="detail-stat">
<span class="detail-stat-num">545</span>
<span class="detail-stat-label">拥有天数</span>
</div>
</div>
<div class="detail-section">
<h3>基本信息</h3>
<div class="info-row"><span>品类</span><span>上衣</span></div>
<div class="info-row"><span>颜色</span><span>白色</span></div>
<div class="info-row"><span>品牌</span><span>优衣库</span></div>
<div class="info-row"><span>材质</span><span>棉</span></div>
<div class="info-row"><span>购买日期</span><span>2025-03-15</span></div>
<div class="info-row"><span>购买价格</span><span>¥199</span></div>
</div>
<div class="detail-section">
<h3>上次清洁</h3>
<div class="info-row"><span>清洁日期</span><span>2026-09-05</span></div>
<div class="info-row"><span>清洁方式</span><span>机洗</span></div>
<div class="info-row"><span>距今</span><span>6天</span></div>
</div>
<div class="detail-section">
<h3>搭配记录</h3>
<div class="outfit-mini-list">
<div class="outfit-mini">
<div class="mini-items">
<span>👕</span><span>👖</span><span>👟</span>
</div>
<p>2026-09-10 · 通勤</p>
</div>
<div class="outfit-mini">
<div class="mini-items">
<span>👕</span><span>👗</span><span>👜</span>
</div>
<p>2026-09-05 · 约会</p>
</div>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
<!-- Page: Outfit Recommendations -->
<div class="page" id="page-outfit">
<div class="page-header">
<button class="icon-btn" onclick="showPage('page-home')">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<h1 class="page-title center-title">搭配推荐</h1>
<div style="width:40px;"></div>
</div>
<div class="scroll-content">
<div class="outfit-filters">
<div class="filter-group">
<label>季节</label>
<div class="filter-btns">
<button class="filter-btn active">春</button>
<button class="filter-btn">夏</button>
<button class="filter-btn">秋</button>
<button class="filter-btn">冬</button>
</div>
</div>
<div class="filter-group">
<label>场合</label>
<div class="filter-btns">
<button class="filter-btn active">通勤</button>
<button class="filter-btn">休闲</button>
<button class="filter-btn">约会</button>
<button class="filter-btn">运动</button>
</div>
</div>
</div>
<div class="outfit-result-list">
<div class="outfit-result-card">
<div class="outfit-result-header">
<span class="outfit-result-tag">通勤首选</span>
<span class="outfit-result-score">匹配度 92%</span>
</div>
<div class="outfit-result-items">
<div class="result-item" style="background:#F0E8DF;"><span>👕</span><small>白衬衫</small></div>
<div class="result-item" style="background:#E5DDD4;"><span>👖</span><small>卡其裤</small></div>
<div class="result-item" style="background:#DDD5CC;"><span>🧥</span><small>风衣</small></div>
<div class="result-item" style="background:#E8E0D7;"><span>👟</span><small>小白鞋</small></div>
</div>
<div class="outfit-result-ai">
<p class="ai-label">🤖 AI 解析</p>
<p class="ai-text">这套搭配以大地色系为主,白衬衫增添清爽感,适合春秋季通勤。风衣可应对早晚温差。</p>
</div>
<div class="outfit-result-actions">
<button class="action-btn" onclick="showToast('已保存')">保存搭配</button>
<button class="action-btn secondary">查看详情</button>
</div>
</div>
<div class="outfit-result-card">
<div class="outfit-result-header">
<span class="outfit-result-tag">周末休闲</span>
<span class="outfit-result-score">匹配度 87%</span>
</div>
<div class="outfit-result-items">
<div class="result-item" style="background:#E0D8CF;"><span>👔</span><small>条纹T恤</small></div>
<div class="result-item" style="background:#D8D0C7;"><span>👖</span><small>牛仔裤</small></div>
<div class="result-item" style="background:#E8E0D7;"><span>👟</span><small>运动鞋</small></div>
</div>
<div class="outfit-result-ai">
<p class="ai-label">🤖 AI 解析</p>
<p class="ai-text">休闲舒适的日常搭配,条纹元素增添活力,牛仔裤百搭耐看,适合周末出游。</p>
</div>
<div class="outfit-result-actions">
<button class="action-btn" onclick="showToast('已保存')">保存搭配</button>
<button class="action-btn secondary">查看详情</button>
</div>
</div>
<div class="outfit-result-card">
<div class="outfit-result-header">
<span class="outfit-result-tag">约会穿搭</span>
<span class="outfit-result-score">匹配度 85%</span>
</div>
<div class="outfit-result-items">
<div class="result-item" style="background:#F5EDE6;"><span>👗</span><small>碎花裙</small></div>
<div class="result-item" style="background:#F0E8DF;"><span>👜</span><small>手提包</small></div>
<div class="result-item" style="background:#E8E0D7;"><span>👠</span><small>单鞋</small></div>
</div>
<div class="outfit-result-ai">
<p class="ai-label">🤖 AI 解析</p>
<p class="ai-text">碎花连衣裙优雅浪漫,搭配精致手提包提升整体质感,适合约会或下午茶场合。</p>
</div>
<div class="outfit-result-actions">
<button class="action-btn" onclick="showToast('已保存')">保存搭配</button>
<button class="action-btn secondary">查看详情</button>
</div>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
<!-- Page: Calendar -->
<div class="page" id="page-calendar">
<div class="page-header">
<h1 class="page-title">穿搭日历</h1>
</div>
<div class="scroll-content">
<div class="calendar-month">
<div class="calendar-nav">
<button class="icon-btn small">‹</button>
<h3>2026年9月</h3>
<button class="icon-btn small">›</button>
</div>
<div class="calendar-weekdays">
<span>日</span><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span>
</div>
<div class="calendar-days">
<span class="day empty"></span><span class="day empty"></span><span class="day">1</span><span class="day">2</span><span class="day">3</span><span class="day">4</span><span class="day">5</span>
<span class="day">6</span><span class="day">7</span><span class="day">8</span><span class="day">9</span><span class="day">10</span><span class="day active has-outfit">11</span><span class="day">12</span>
<span class="day">13</span><span class="day">14</span><span class="day">15</span><span class="day">16</span><span class="day">17</span><span class="day">18</span><span class="day">19</span>
<span class="day">20</span><span class="day">21</span><span class="day">22</span><span class="day">23</span><span class="day">24</span><span class="day">25</span><span class="day">26</span>
<span class="day">27</span><span class="day">28</span><span class="day">29</span><span class="day">30</span>
</div>
</div>
<div class="calendar-detail">
<h3>9月11日 · 周四</h3>
<div class="calendar-outfit-card">
<div class="outfit-mini-items">
<span>👕</span><span>👖</span><span>🧥</span><span>👟</span>
</div>
<div class="calendar-outfit-info">
<p>简约通勤风</p>
<small>白衬衫 + 卡其裤 + 风衣 + 小白鞋</small>
</div>
</div>
</div>
<div class="calendar-history">
<h3>最近穿搭</h3>
<div class="history-list">
<div class="history-item">
<div class="history-date">
<span class="history-day">10</span>
<small>周三</small>
</div>
<div class="history-items">
<span>👔</span><span>👖</span><span>👟</span>
</div>
<span class="history-label">休闲</span>
</div>
<div class="history-item">
<div class="history-date">
<span class="history-day">9</span>
<small>周二</small>
</div>
<div class="history-items">
<span>👕</span><span>👗</span><span>👜</span>
</div>
<span class="history-label">约会</span>
</div>
<div class="history-item">
<div class="history-date">
<span class="history-day">8</span>
<small>周一</small>
</div>
<div class="history-items">
<span>👕</span><span>👖</span><span>🧥</span>
</div>
<span class="history-label">通勤</span>
</div>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
<!-- Page: Profile -->
<div class="page" id="page-profile">
<div class="page-header">
<h1 class="page-title">我的</h1>
</div>
<div class="scroll-content">
<div class="profile-header">
<div class="avatar">👤</div>
<h2>时尚达人</h2>
<p>已使用 545 天</p>
</div>
<div class="profile-stats">
<div class="profile-stat">
<span class="ps-num">42</span>
<span class="ps-label">衣物</span>
</div>
<div class="profile-stat">
<span class="ps-num">15</span>
<span class="ps-label">搭配</span>
</div>
<div class="profile-stat">
<span class="ps-num">73%</span>
<span class="ps-label">利用率</span>
</div>
<div class="profile-stat">
<span class="ps-num">28</span>
<span class="ps-label">天连续</span>
</div>
</div>
<div class="menu-section">
<div class="menu-item" onclick="showPage('page-calendar')">
<div class="menu-icon">📅</div>
<span>穿搭日历</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item">
<div class="menu-icon">❤️</div>
<span>我的收藏</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item">
<div class="menu-icon">🧺</div>
<span>清洁管理</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item">
<div class="menu-icon">📊</div>
<span>风格统计</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<div class="menu-section">
<div class="menu-item">
<div class="menu-icon">☁️</div>
<span>云端同步</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item">
<div class="menu-icon">📤</div>
<span>导出数据</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item">
<div class="menu-icon">⚙️</div>
<span>设置</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="menu-item">
<div class="menu-icon">❓</div>
<span>帮助与反馈</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
<!-- Toast -->
<div class="toast hidden" id="toast"></div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay hidden" id="delete-modal">
<div class="modal">
<h3>确认删除</h3>
<p>确定要将这件衣物从衣橱中移除吗?此操作不可撤销。</p>
<div class="modal-actions">
<button class="modal-btn cancel" onclick="hideModal()">取消</button>
<button class="modal-btn confirm" onclick="hideModal();showToast('已删除');showPage('page-closet')">删除</button>
</div>
</div>
</div>
<!-- Bottom Navigation -->
<nav class="bottom-nav">
<button class="nav-item active" onclick="navTo(this, 'page-home')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
<span>首页</span>
</button>
<button class="nav-item" onclick="navTo(this, 'page-outfit')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
<span>搭配</span>
</button>
<button class="nav-item center-btn" onclick="showPage('page-add')">
<div class="center-btn-circle">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
</button>
<button class="nav-item" onclick="navTo(this, 'page-closet')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
<span>衣橱</span>
</button>
<button class="nav-item" onclick="navTo(this, 'page-profile')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>我的</span>
</button>
</nav>
</div>
<script src="script.js"></script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
let currentPage = 'page-home';
let currentView = 'grid';
function showPage(pageId) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(pageId).classList.add('active');
currentPage = pageId;
// Update nav
const navMap = {
'page-home': 0,
'page-outfit': 1,
'page-add': 2,
'page-closet': 3,
'page-profile': 4,
'page-detail': -1,
'page-calendar': -1
};
const idx = navMap[pageId];
if (idx >= 0) {
document.querySelectorAll('.nav-item').forEach((n, i) => {
n.classList.toggle('active', i === idx);
});
}
}
function navTo(el, pageId) {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
showPage(pageId);
}
function setView(view) {
currentView = view;
const grid = document.getElementById('closet-grid');
const list = document.getElementById('closet-list');
const gridBtn = document.getElementById('grid-view-btn');
const listBtn = document.getElementById('list-view-btn');
if (view === 'grid') {
grid.style.display = 'grid';
list.classList.add('hidden');
gridBtn.classList.add('active');
listBtn.classList.remove('active');
} else {
grid.style.display = 'none';
list.classList.remove('hidden');
gridBtn.classList.remove('active');
listBtn.classList.add('active');
}
}
function filterCloset(btn, cat) {
document.querySelectorAll('.filter-bar:first-of-type .filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cards = document.querySelectorAll('.clothing-card');
cards.forEach(card => {
if (cat === 'all' || card.dataset.cat === cat) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
function selectColor(el) {
document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active'));
el.classList.add('active');
}
function simulateUpload() {
const placeholder = document.getElementById('upload-placeholder');
const preview = document.getElementById('upload-preview');
if (placeholder && preview) {
placeholder.style.display = 'none';
preview.classList.remove('hidden');
}
}
function resetUpload() {
const placeholder = document.getElementById('upload-placeholder');
const preview = document.getElementById('upload-preview');
if (placeholder && preview) {
placeholder.style.display = '';
preview.classList.add('hidden');
}
}
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.remove('hidden');
setTimeout(() => toast.classList.add('hidden'), 1500);
}
function confirmDelete() {
document.getElementById('delete-modal').classList.remove('hidden');
}
function hideModal() {
document.getElementById('delete-modal').classList.add('hidden');
}
function refreshOutfit() {
showToast('已刷新推荐搭配');
}
// Tag toggle
document.addEventListener('click', function(e) {
if (e.target.classList.contains('selectable-tag')) {
e.target.classList.toggle('active');
}
if (e.target.classList.contains('filter-btn') && !e.target.closest('.filter-bar:first-of-type') && !e.target.closest('.filter-group')) {
const group = e.target.closest('.filter-btns');
if (group) {
group.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
}
}
});
+1494
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Wardrobe Android APK 本地打包脚本 (WSL 内运行)
# 用法: bash scripts/build-apk.sh [debug|release]
# 参考: D:\workbench\iboard\scripts\wsl\build-apk.sh
# 尽量复用本机已装的 Android SDK / NDK, 不重复下载
set -euo pipefail
WIN_PROJECT='/mnt/d/workbench/Wardrobe'
cd "$WIN_PROJECT"
VARIANT="${1:-debug}"
echo "构建变体: $VARIANT"
# ---- 复用已有 Android SDK / NDK (不重复安装) ----
# 优先用 ANDROID_HOME 环境变量, 找不到再探测常见路径
if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "$ANDROID_HOME/platforms" ]; then
for cand in "$HOME/android-sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do
if [ -d "$cand/platforms" ]; then
export ANDROID_HOME="$cand"
break
fi
done
fi
if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "$ANDROID_HOME/platforms" ]; then
echo "ERROR: 未找到可复用的 Android SDK (ANDROID_HOME)" >&2
exit 1
fi
echo "复用 SDK: $ANDROID_HOME"
# NDK: 复用已装的 27.2.x, 找不到报错
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/27.2.12479018}"
NDK_TOOLCHAIN_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
if [ ! -d "$NDK_TOOLCHAIN_BIN" ]; then
echo "ERROR: NDK toolchain 不存在: $NDK_TOOLCHAIN_BIN, 请确认已安装 NDK 27.2.x" >&2
exit 1
fi
echo "复用 NDK: $ANDROID_NDK_HOME"
export PATH="$NDK_TOOLCHAIN_BIN:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
# ---- 0. 首次需要 android 工程 ----
cd src-tauri
if [ ! -d "gen/android" ]; then
echo "=== 0. 初始化 Android 工程 (一次性) ==="
npx tauri android init --ci
fi
cd "$WIN_PROJECT"
# ---- 1. 构建前端 ----
echo "=== 1. 构建前端 ==="
npm run build
# ---- 1.5 拷贝 dist/ 到 Android 工程 assets/ ----
ANDROID_ASSETS="$WIN_PROJECT/src-tauri/gen/android/app/src/main/assets"
mkdir -p "$ANDROID_ASSETS"
find "$ANDROID_ASSETS" -mindepth 1 -maxdepth 1 ! -name dexopt ! -name tauri.conf.json -exec rm -rf {} +
cp -r dist/. "$ANDROID_ASSETS/"
echo "前端 bundle 已拷贝到 $ANDROID_ASSETS"
# ---- 2. 构建 ----
cd src-tauri
if [ "$VARIANT" = "release" ]; then
# release: 手工 cargo 编 4 ABI, gradle 只跑打包
RUST_BUILD_TASKS=(
":app:rustBuildArm64Release"
":app:rustBuildArmRelease"
":app:rustBuildX86_64Release"
":app:rustBuildX86Release"
)
SKIP_ARGS=()
for t in "${RUST_BUILD_TASKS[@]}"; do SKIP_ARGS+=(-x "${t#:}"); done
ANDROID_TARGETS=(
"aarch64-linux-android"
"armv7-linux-androideabi"
"x86_64-linux-android"
"i686-linux-android"
)
echo "[release] 手工 cargo 编 lib (4 个 ABI)..."
for target in "${ANDROID_TARGETS[@]}"; do
echo " [cargo] build lib release for $target ..."
cargo build \
--target "$target" \
--features custom-protocol \
--lib \
--release
done
echo " [gradle] assembleRelease -x rustBuild* ..."
cd gen/android
./gradlew --no-daemon assembleRelease "${SKIP_ARGS[@]}"
APK_DIR="$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/universal/release"
SIGNED_APK="$APK_DIR/app-universal-release.apk"
UNSIGNED_APK="$APK_DIR/app-universal-release-unsigned.apk"
if [ -f "$SIGNED_APK" ]; then
APK_PATH="$SIGNED_APK"
APK_STATUS="已签名"
elif [ -f "$UNSIGNED_APK" ]; then
APK_PATH="$UNSIGNED_APK"
APK_STATUS="未签名"
else
APK_PATH=""
APK_STATUS="未找到"
fi
echo
echo "=== 产物 ($APK_STATUS) ==="
if [ -n "$APK_PATH" ]; then
ls -lh "$APK_PATH"
else
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi
if [ "$APK_STATUS" = "已签名" ]; then
echo
echo "安装: adb install -r $APK_PATH"
fi
else
echo "[debug] 直接走 Tauri CLI"
npx tauri android build --apk
APK_DIR="$WIN_PROJECT/src-tauri/target/debug/bundle/android/app/build/outputs/apk/debug"
echo
echo "=== 产物 ==="
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
fi
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env node
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
// 用法:
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
// 流程:
// 1) 校验 dist/ 已构建
// 2) 生成 bundle.tar.gz (dist/ 内容)
// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
// 5) 全部产物输出到 out 目录 (默认 ota-package/)
//
// minisign 安装: 见 README "OTA 升级" 章节
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const args = process.argv.slice(2)
function getArg(name, def) {
const i = args.indexOf(name)
return i >= 0 && args[i + 1] ? args[i + 1] : def
}
const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
const MIN_BINARY = getArg('--min-binary', '')
const NOTES = getArg('--notes', '')
const NO_SIGN = args.includes('--no-sign')
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const APP_VERSION = pkg.version || '0.0.0'
const DIST = path.join(ROOT, 'dist')
if (!fs.existsSync(path.join(DIST, 'index.html'))) {
console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
process.exit(1)
}
// 准备输出目录
fs.mkdirSync(OUT_DIR, { recursive: true })
// 1) 生成 bundle.tar.gz
const BUNDLE_NAME = 'wardrobe-ota.tar.gz'
const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('[build-ota] tar 打包失败')
process.exit(1)
}
const bundleStat = fs.statSync(BUNDLE_PATH)
const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
// 2) 签名 (minisign)
let signature = ''
let pubkey = ''
if (!NO_SIGN) {
// CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
let KEY_PATH_TMP = null
let KEY_PATH_USE = KEY_PATH
if (KEY_FROM_ENV) {
KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
KEY_PATH_USE = KEY_PATH_TMP
}
if (!fs.existsSync(KEY_PATH_USE)) {
console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
console.error(' 注入方式二选一:')
console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>')
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
process.exit(1)
}
// minisign -Sm <file> -s <key> 生成 detached signature
const sigR = spawnSync('minisign', ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE], { stdio: 'inherit' })
// 清理 CI 临时私钥 (无论成功失败都删)
if (KEY_PATH_TMP) {
try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
KEY_PATH_TMP = null
}
if (sigR.status !== 0) {
console.error('[build-ota] minisign 签名失败')
process.exit(1)
}
const sigPath = BUNDLE_PATH + '.minisig'
signature = fs.readFileSync(sigPath, 'utf8').trim()
// 尝试同步 pubkey 到 tauri.conf.json (如果存在)
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
if (fs.existsSync(PUB_PATH)) {
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
const lines = pubkey.split(/\r?\n/)
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
}
}
// 3) 写 latest.json
const minBinary = MIN_BINARY || (() => {
// 默认从 src-tauri/tauri.conf.json 读 version
try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
catch { return APP_VERSION }
})()
const manifest = {
version: APP_VERSION + '-ota',
sequence: SEQUENCE,
min_binary_version: minBinary,
bundle: BUNDLE_NAME,
bundle_size: bundleStat.size,
bundle_sha256: bundleSha256,
signature,
notes: NOTES,
pub_date: new Date().toISOString(),
mandatory: false,
// 内部用: 前端 web 模式比对 build_id
build_id: 'seq-' + SEQUENCE,
}
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
// 4) 提示
console.log('')
console.log('=== OTA 包生成完成 ===')
console.log('输出目录: ', OUT_DIR)
console.log('版本: ', manifest.version)
console.log('sequence: ', manifest.sequence)
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
console.log('bundle sha256:', bundleSha256)
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
if (pubkey) {
console.log('')
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
console.log(' ' + pubkey)
}
console.log('')
console.log('下一步: 把这两个文件发布到 OTA 存储:')
console.log(' ' + BUNDLE_NAME)
console.log(' latest.json')
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# CI 专用: 为 Tauri Android release 构建注入 release 签名配置
# 在 `tauri android init` 之后、`tauri android build` 之前运行。
# 参考: D:\workbench\iboard\scripts\ci\android-signing.sh
#
# 依赖 (由调用方注入环境变量, 不要写死密钥到脚本/仓库):
# ANDROID_KEYSTORE_B64 - base64 编码的 keystore 文件内容 (CI secret)
# ANDROID_KEY_ALIAS - 密钥别名 (CI secret/variable)
# ANDROID_KEY_PASSWORD - store & key 密码 (CI secret; 本脚本两者用同一个)
#
# 作用:
# 1. 把 base64 密钥解码写到 $RUNNER_TEMP/wardrobe-release.keystore
# 2. 生成 gen/android/keystore.properties (Tauri 签名约定读取此文件)
# 3. 修改 gen/android/app/build.gradle.kts, 添加 signingConfigs.release
# 并让 buildTypes.release 使用它, 产出已签名 APK
set -euo pipefail
APP_GRADLE="src-tauri/gen/android/app/build.gradle.kts"
echo "[signing] 注入 Android release 签名配置..."
# --- 1. 解码 keystore ---
if [ -z "${ANDROID_KEYSTORE_B64:-}" ]; then
echo "[signing] ERROR: ANDROID_KEYSTORE_B64 未设置, 无法签名 release APK" >&2
exit 1
fi
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS 未设置}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD 未设置}"
KEYSTORE_FILE="${RUNNER_TEMP:-/tmp}/wardrobe-release.keystore"
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "$KEYSTORE_FILE"
# --- 2. 写 keystore.properties (Tauri 官方约定的签名配置文件位置) ---
KEYSTORE_PROPERTIES="src-tauri/gen/android/keystore.properties"
cat > "$KEYSTORE_PROPERTIES" <<EOF
keyAlias=${ANDROID_KEY_ALIAS}
password=${ANDROID_KEY_PASSWORD}
storeFile=${KEYSTORE_FILE}
EOF
# --- 3. 修改 build.gradle.kts 注入 signingConfigs ---
if [ ! -f "$APP_GRADLE" ]; then
echo "[signing] ERROR: 找不到 $APP_GRADLE (请先运行 tauri android init)" >&2
exit 1
fi
if grep -q 'create("release")' "$APP_GRADLE" 2>/dev/null; then
echo "[signing] build.gradle.kts 已有 release signingConfigs, 跳过注入"
else
python3 - "$APP_GRADLE" <<'PYEOF'
import sys, re
path = sys.argv[1]
src = open(path, encoding='utf-8').read()
if 'import java.io.FileInputStream' not in src:
imp = re.search(r'^import .*$', src, flags=re.M)
new_import = 'import java.io.FileInputStream\n'
if imp:
src = src[:imp.end()] + '\n' + new_import + src[imp.end():]
else:
src = new_import + src
m = re.search(r'(\n[ \t]*)(buildTypes\s*\{)', src)
if not m:
print('[signing] 未找到 buildTypes 块, 中止', file=sys.stderr)
sys.exit(1)
indent = m.group(1)
inner_indent = indent + ' '
signing_block = (
f'{inner_indent}signingConfigs {{\n'
f'{inner_indent} create("release") {{\n'
f'{inner_indent} val keystorePropertiesFile = rootProject.file("keystore.properties")\n'
f'{inner_indent} val keystoreProperties = Properties()\n'
f'{inner_indent} if (keystorePropertiesFile.exists()) {{\n'
f'{inner_indent} keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n'
f'{inner_indent} }}\n'
f'{inner_indent} keyAlias = keystoreProperties["keyAlias"] as? String ?: ""\n'
f'{inner_indent} keyPassword = keystoreProperties["password"] as? String ?: ""\n'
f'{inner_indent} storeFile = file(keystoreProperties["storeFile"] as? String ?: "")\n'
f'{inner_indent} storePassword = keystoreProperties["password"] as? String ?: ""\n'
f'{inner_indent} }}\n'
f'{inner_indent}}}\n'
)
src = src.replace(m.group(0), signing_block + m.group(0), 1)
rel = re.search(r'(\n[ \t]*)(getByName\("release"\)\s*\{)', src)
if not rel:
rel = re.search(r'(\n[ \t]*)(release\s*\{)', src)
if rel:
rel_indent = rel.group(1) + ' '
inject = rel_indent + 'signingConfig = signingConfigs.getByName("release")\n'
src = src.replace(rel.group(0), rel.group(0) + inject, 1)
open(path, 'w', encoding='utf-8').write(src)
print('[signing] build.gradle.kts 已注入 import + signingConfigs + release signingConfig')
PYEOF
fi
# --- 4. 放行明文 HTTP (release APK 默认拦截 http://) ---
if [ ! -f "$APP_GRADLE" ]; then
echo "[signing] ERROR: 找不到 $APP_GRADLE (明文放行跳过)" >&2
exit 1
fi
python3 - "$APP_GRADLE" <<'PYEOF'
import sys, re
path = sys.argv[1]
src = open(path, encoding='utf-8').read()
new = re.sub(
r'(manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*)"false"',
r'\1"true"',
src,
)
if 'usesCleartextTraffic' not in src:
m = re.search(r'^([ \t]*)getByName\("release"\)\s*\{', new, flags=re.M)
if not m:
m = re.search(r'^([ \t]*)release\s*\{', new, flags=re.M)
if m:
inner = m.group(1) + ' '
inject = f'\n{inner}manifestPlaceholders["usesCleartextTraffic"] = "true"'
new = new.replace(m.group(0), m.group(0) + inject, 1)
else:
print('[signing] 警告: 未找到 release 构建块, 明文放行注入跳过', file=sys.stderr)
open(path, 'w', encoding='utf-8').write(new)
print('[signing] usesCleartextTraffic 已设为 true (release 放行明文 HTTP)')
PYEOF
echo "[signing] 完成: keystore.properties 已写, build.gradle.kts 已注入"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// 生成 minisign 密钥对 (ota-package/hotswap.{key,pub})
// 首次跑一遍: node scripts/ota-genkey.mjs
// - hotswap.key: 私钥, 不要提交, 部署时由 CI 注入 (环境变量 / secret)
// - hotswap.pub: 公钥, 写到 src-tauri/tauri.conf.json 的 plugins.hotswap.pubkey
//
// 依赖: minisign (https://jedisct1.github.io/minisign/)
// - Windows: scoop install minisign 或 choco install minisign (或下载官方 win64 二进制)
// - macOS: brew install minisign
// - Linux: apt install minisign
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..')
const OUT_DIR = path.join(ROOT, 'ota-package')
fs.mkdirSync(OUT_DIR, { recursive: true })
const KEY = path.join(OUT_DIR, 'hotswap.key')
const PUB = path.join(OUT_DIR, 'hotswap.pub')
if (fs.existsSync(KEY) || fs.existsSync(PUB)) {
console.error('[ota-genkey] 已存在密钥, 如需重新生成请先删除 ota-package/hotswap.{key,pub}')
console.error(' 注意: 重新生成密钥会导致旧 bundle 无法被客户端验证, 慎用!')
process.exit(1)
}
// 检查 minisign 是否存在
const which = spawnSync('minisign', ['-v'], { stdio: 'pipe' })
if (which.status !== 0 && which.error) {
console.error('[ota-genkey] 未找到 minisign 命令, 请先安装:')
console.error(' Windows: scoop install minisign')
console.error(' macOS: brew install minisign')
console.error(' Linux: apt install minisign')
process.exit(1)
}
// -G: 生成 -W: 无密码 -p: 公钥输出 -s: 私钥输出
const r = spawnSync('minisign', ['-G', '-W', '-p', PUB, '-s', KEY], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('[ota-genkey] minisign 生成失败')
process.exit(1)
}
console.log('\n=== 密钥生成完成 ===')
console.log('私钥: ' + KEY + ' (不要提交到 git!)')
console.log('公钥: ' + PUB)
const pubText = fs.readFileSync(PUB, 'utf8')
const lines = pubText.split(/\r?\n/)
const pubB64 = lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ'))
console.log('\n请把下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
console.log(' ' + (pubB64 || '').trim())
// 自动写 .gitignore 提醒
const gitignore = path.join(ROOT, '.gitignore')
if (fs.existsSync(gitignore)) {
let txt = fs.readFileSync(gitignore, 'utf8')
if (!txt.includes('ota-package/hotswap.key')) {
if (!txt.endsWith('\n')) txt += '\n'
txt += '\n# OTA 升级私钥, 不要提交\nota-package/hotswap.key\n'
fs.writeFileSync(gitignore, txt)
console.log('\n[ota-genkey] 已把 ota-package/hotswap.key 加入 .gitignore')
}
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# 把 OTA 升级包 (bundle + latest.json) 上传到 Cloudflare R2 (公开桶, 前缀 store/ota/)
# 用法: bash scripts/upload-ota-r2.sh <OTA_DIR>
# 默认 <OTA_DIR> = ota-package
# 说明:
# - bundle 固定名 wardrobe-ota.tar.gz (与 build-ota.mjs 保持一致), 覆盖式上传
# - latest.json 一并上传, 便于把 R2 作为 OTA 静态存储 (配合 OTA_PUBLIC_BASE 使用)
#
# 依赖环境变量 (通常来自配置注册中心 CommonExternalService / cloudflare):
# CF_API_TOKEN Cloudflare API token (r2/CF_API_TOKEN)
# CF_R2_BUCKET (可选, 默认 store)
# CF_R2_PUBLIC_URL (可选; 若设置, 同时输出可直接给 OTA_PUBLIC_BASE 使用的下载地址)
set -euo pipefail
OTA_DIR="${1:-ota-package}"
BUNDLE="wardrobe-ota.tar.gz"
HAS_BUNDLE=0
[ -f "$OTA_DIR/$BUNDLE" ] && HAS_BUNDLE=1
: "${CF_API_TOKEN:?缺少 CF_API_TOKEN (注册中心 key: r2/CF_API_TOKEN)}"
export CLOUDFLARE_API_TOKEN="$CF_API_TOKEN"
BUCKET="${CF_R2_BUCKET:-store}"
CF_R2_PUBLIC_URL="${CF_R2_PUBLIC_URL:-https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev}"
if ! command -v wrangler >/dev/null 2>&1; then
echo "安装 wrangler ..."
npm i -g wrangler
fi
if [ "$HAS_BUNDLE" = "1" ]; then
echo "上传 ${BUCKET}/ota/${BUNDLE} ..."
wrangler r2 object put "${BUCKET}/ota/${BUNDLE}" \
--file "$OTA_DIR/$BUNDLE" \
--content-type application/gzip \
--remote
fi
if [ -f "$OTA_DIR/latest.json" ]; then
echo "上传 ${BUCKET}/ota/latest.json ..."
wrangler r2 object put "${BUCKET}/ota/latest.json" \
--file "$OTA_DIR/latest.json" \
--content-type application/json \
--remote
fi
echo "已上传."
echo "bundle 下载地址: ${CF_R2_PUBLIC_URL}/ota/${BUNDLE}"
echo "可将 OTA_PUBLIC_BASE=${CF_R2_PUBLIC_URL} 传给赋予了 check 端点的 server, 让客户端直连 R2 下载 bundle."
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# 把 APK 上传到 Cloudflare R2 (公开桶) via wrangler
# 用法: bash scripts/upload-r2.sh <APK_PATH> [OBJECT_KEY]
#
# 依赖环境变量 (通常来自配置注册中心 CommonExternalService / cloudflare):
# CF_API_TOKEN Cloudflare API token (r2/CF_API_TOKEN), wrangler 会从 token 自动解析账号 ID
# CF_R2_BUCKET (可选, 默认 store)
# CF_R2_PUBLIC_URL (可选, 默认 pub-98ee77ce79984c2cad0dc1c300901158.r2.dev, 仅用于打印下载地址)
#
# 说明: 与 kit 项目一致, 固定路径覆盖式上传, 保证有一个稳定的"最新"下载地址。
set -euo pipefail
APK_PATH="${1:?用法: bash scripts/upload-r2.sh <APK_PATH> [OBJECT_KEY]}"
OBJECT_KEY="${2:-data/android/$(basename "$APK_PATH")}"
: "${CF_API_TOKEN:?缺少 CF_API_TOKEN (注册中心 key: r2/CF_API_TOKEN)}"
export CLOUDFLARE_API_TOKEN="$CF_API_TOKEN"
BUCKET="${CF_R2_BUCKET:-store}"
CF_R2_PUBLIC_URL="${CF_R2_PUBLIC_URL:-https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev}"
if ! command -v wrangler >/dev/null 2>&1; then
echo "安装 wrangler ..."
npm i -g wrangler
fi
echo "上传到 R2: ${BUCKET}/${OBJECT_KEY}"
wrangler r2 object put "${BUCKET}/${OBJECT_KEY}" \
--file "$APK_PATH" \
--content-type application/vnd.android.package-archive \
--remote
echo "已上传."
echo "公共下载地址: ${CF_R2_PUBLIC_URL}/${OBJECT_KEY}"
+44
View File
@@ -0,0 +1,44 @@
import 'dotenv/config'
import path from 'node:path'
import fs from 'node:fs'
import express from 'express'
import cors from 'cors'
import otaRouter from './routes/ota.js'
// 默认 3002: 与 iboard (3001) 同机部署时不冲突
const PORT = Number(process.env.PORT) || 3002
const DIST_DIR = path.join(process.cwd(), 'dist')
const HAS_DIST = fs.existsSync(DIST_DIR)
const app = express()
app.use(cors())
app.use(express.json())
// 健康检查
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
// OTA 接口公开 (检查/下载更新不需要登录), 必须挂载在最前面
app.use('/api', otaRouter)
// 静态资源 (Vite build 产物)
if (HAS_DIST) {
app.use(express.static(DIST_DIR, { index: false, maxAge: '1h', etag: true }))
// SPA fallback: 非 /api/* 的请求一律返回 index.html, 由前端路由处理
app.get(/^\/(?!api\/).*/, (_req, res, next) => {
const indexHtml = path.join(DIST_DIR, 'index.html')
if (!fs.existsSync(indexHtml)) return next()
res.sendFile(indexHtml)
})
}
// 统一错误处理
app.use((err, _req, res, _next) => {
console.error('[api error]', err)
res.status(500).json({ error: err.message || '服务器内部错误' })
})
app.listen(PORT, () => {
console.log(`[wardrobe-api] listening on http://127.0.0.1:${PORT}`)
console.log(`[wardrobe-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
})
+100
View File
@@ -0,0 +1,100 @@
// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定)
// GET /api/ota/check/:current_sequence
// - 客户端传当前 sequence, 服务端判断是否有更新
// - 有: 200 + manifest JSON
// - 无: 204 No Content
// GET /api/ota/bundle/:filename
// - 下载前端 bundle (tar.gz), 供 manifest.url 引用
// GET /api/ota/manifest-web
// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用)
//
// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放:
// - latest.json (由 scripts/build-ota.mjs 生成)
// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size }
// - <filename> (tar.gz 升级包)
import { Router } from 'express'
import fs from 'node:fs'
import path from 'node:path'
const router = Router()
function resolveOtaDir() {
const dir = process.env.OTA_PACKAGE_DIR
? path.resolve(process.env.OTA_PACKAGE_DIR)
: path.resolve(process.cwd(), 'ota-package')
return dir
}
function readLatest() {
const p = path.join(resolveOtaDir(), 'latest.json')
if (!fs.existsSync(p)) return null
try {
return JSON.parse(fs.readFileSync(p, 'utf8'))
} catch (e) {
console.error('[ota] latest.json 解析失败:', e.message)
return null
}
}
// GET /api/ota/check/:current_sequence
// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式
router.get('/ota/check/:current_sequence', (req, res) => {
const currentSeq = Number(req.params.current_sequence)
if (!Number.isFinite(currentSeq) || currentSeq < 0) {
return res.status(400).json({ error: '无效的 sequence' })
}
const latest = readLatest()
if (!latest) {
// 无可用包, 按约定返回 204
return res.status(204).end()
}
// 不新于当前则 204
if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) {
return res.status(204).end()
}
const dir = resolveOtaDir()
const base = process.env.OTA_PUBLIC_BASE || ''
res.json({
version: latest.version,
sequence: latest.sequence,
min_binary_version: latest.min_binary_version || '0.0.0',
url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
signature: latest.signature,
notes: latest.notes || '',
pub_date: latest.pub_date || new Date().toISOString(),
mandatory: !!latest.mandatory,
bundle_size: latest.bundle_size || 0,
})
})
// GET /api/ota/bundle/:filename - 流式下载 bundle
router.get('/ota/bundle/:filename', (req, res) => {
const dir = resolveOtaDir()
// 防越界: 只取 basename
const name = path.basename(req.params.filename)
const p = path.join(dir, name)
if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' })
const stat = fs.statSync(p)
res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Length', stat.size)
res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"')
fs.createReadStream(p).pipe(res)
})
// GET /api/ota/manifest-web - Web 浏览器用的简化版
// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新
router.get('/ota/manifest-web', (_req, res) => {
const latest = readLatest()
if (!latest) return res.json({ available: false })
res.json({
available: true,
version: latest.version,
build_id: latest.build_id || latest.version,
note: latest.notes || '',
size: latest.bundle_size || 0,
})
})
// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方
export default router
+6098
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "wardrobe"
version = "0.1.0"
edition = "2021"
description = "Wardrobe - AI-powered clothing management system"
# 库目标: 桌面 + Android (cdylib) 都需要
[lib]
name = "wardrobe_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
# 前端 OTA 升级 (运行时覆盖 tauri://localhost 资源; 替换 Assets provider)
tauri-plugin-hotswap = "0.0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
duckdb = { version = "0.10", features = ["bundled"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10"
hex = "0.4"
ctrlc = "3"
log = "0.4"
env_logger = "0.11"
[target."cfg(target_os = \"android\")".dependencies]
tauri = { version = "2", features = [] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"hotswap:default"
]
}
+151
View File
@@ -0,0 +1,151 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use log::info;
use crate::db::Database;
use crate::models::*;
const VERIFICATION_CODE: &str = "1111";
const CODE_EXPIRY_SECONDS: i64 = 300;
pub struct AuthManager {
codes: Mutex<HashMap<String, VerificationCode>>,
}
impl AuthManager {
pub fn new() -> Self {
Self {
codes: Mutex::new(HashMap::new()),
}
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
pub fn send_verification(&self, target: &str) -> Result<String, String> {
let code = VERIFICATION_CODE.to_string();
let now = Self::now_secs();
let vc = VerificationCode {
target: target.to_string(),
code: code.clone(),
created_at: now,
expires_at: now + CODE_EXPIRY_SECONDS,
};
self.codes.lock().unwrap().insert(target.to_string(), vc);
info!("Verification code sent to {}: {}", target, code);
Ok(format!("验证码已发送至 {}", target))
}
fn verify_code(&self, target: &str, code: &str) -> bool {
let codes = self.codes.lock().unwrap();
if let Some(vc) = codes.get(target) {
let now = Self::now_secs();
vc.code == code && now < vc.expires_at
} else {
false
}
}
pub fn register(
&self,
db: &Database,
email: Option<&str>,
phone: Option<&str>,
code: &str,
nickname: &str,
) -> Result<AuthResult, String> {
let target = email.or(phone).ok_or("请提供邮箱或手机号")?;
if !self.verify_code(target, code) {
return Ok(AuthResult {
success: false,
message: "验证码无效或已过期".to_string(),
user: None,
token: None,
});
}
if email.is_some() {
if db.get_user_by_email(target).map_err(|e| e.to_string())?.is_some() {
return Ok(AuthResult {
success: false,
message: "该邮箱已注册".to_string(),
user: None,
token: None,
});
}
} else if phone.is_some() {
if db.get_user_by_phone(target).map_err(|e| e.to_string())?.is_some() {
return Ok(AuthResult {
success: false,
message: "该手机号已注册".to_string(),
user: None,
token: None,
});
}
}
let id = uuid::Uuid::new_v4().to_string();
let user = db.create_user(&id, email, phone, nickname).map_err(|e| e.to_string())?;
let token = format!("tok_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
self.codes.lock().unwrap().remove(target);
info!("User registered: {}", id);
Ok(AuthResult {
success: true,
message: "注册成功".to_string(),
user: Some(user),
token: Some(token),
})
}
pub fn login(
&self,
db: &Database,
email: Option<&str>,
phone: Option<&str>,
code: &str,
) -> Result<AuthResult, String> {
let target = email.or(phone).ok_or("请提供邮箱或手机号")?;
if !self.verify_code(target, code) {
return Ok(AuthResult {
success: false,
message: "验证码无效或已过期".to_string(),
user: None,
token: None,
});
}
let user = if email.is_some() {
db.get_user_by_email(target).map_err(|e| e.to_string())?
} else {
db.get_user_by_phone(target).map_err(|e| e.to_string())?
};
self.codes.lock().unwrap().remove(target);
match user {
Some(u) => {
let token = format!("tok_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
Ok(AuthResult {
success: true,
message: "登录成功".to_string(),
user: Some(u),
token: Some(token),
})
}
None => Ok(AuthResult {
success: false,
message: "账号不存在,请先注册".to_string(),
user: None,
token: None,
}),
}
}
}
+395
View File
@@ -0,0 +1,395 @@
use duckdb::{Connection, params};
use std::sync::Mutex;
use std::path::PathBuf;
use log::info;
use crate::models::*;
pub struct Database {
conn: Mutex<Connection>,
path: PathBuf,
}
impl Database {
pub fn new(data_dir: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
std::fs::create_dir_all(data_dir)?;
let db_path = data_dir.join("wardrobe.duckdb");
let conn = Connection::open(&db_path)?;
let db = Self {
conn: Mutex::new(conn),
path: db_path,
};
db.init_tables()?;
info!("DuckDB initialized at {:?}", db.path);
Ok(db)
}
fn init_tables(&self) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("
CREATE TABLE IF NOT EXISTS users (
id VARCHAR PRIMARY KEY,
email VARCHAR UNIQUE,
phone VARCHAR UNIQUE,
nickname VARCHAR NOT NULL DEFAULT '',
avatar_path VARCHAR,
created_at VARCHAR NOT NULL
);
CREATE TABLE IF NOT EXISTS clothing_items (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
category VARCHAR NOT NULL,
color VARCHAR NOT NULL,
brand VARCHAR,
material VARCHAR,
seasons VARCHAR DEFAULT '[]',
occasions VARCHAR DEFAULT '[]',
formality INTEGER DEFAULT 50,
thickness INTEGER DEFAULT 50,
image_path VARCHAR,
purchase_date VARCHAR,
purchase_price DOUBLE,
last_cleaned VARCHAR,
clean_method VARCHAR,
wear_count INTEGER DEFAULT 0,
last_worn VARCHAR,
is_favorite BOOLEAN DEFAULT FALSE,
created_at VARCHAR NOT NULL,
updated_at VARCHAR NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS outfits (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
item_ids VARCHAR DEFAULT '[]',
occasion VARCHAR,
season VARCHAR,
match_score INTEGER,
ai_analysis VARCHAR,
is_saved BOOLEAN DEFAULT FALSE,
created_at VARCHAR NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS daily_records (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
date VARCHAR NOT NULL,
outfit_id VARCHAR,
weather_temp DOUBLE,
weather_condition VARCHAR,
notes VARCHAR,
created_at VARCHAR NOT NULL,
UNIQUE(user_id, date),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_clothing_user ON clothing_items(user_id);
CREATE INDEX IF NOT EXISTS idx_clothing_category ON clothing_items(category);
CREATE INDEX IF NOT EXISTS idx_outfits_user ON outfits(user_id);
CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_records(user_id, date);
")?;
Ok(())
}
// ── User CRUD ──
pub fn create_user(&self, id: &str, email: Option<&str>, phone: Option<&str>, nickname: &str) -> Result<User, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, email, phone, nickname, now],
)?;
Ok(User {
id: id.to_string(),
email: email.map(String::from),
phone: phone.map(String::from),
nickname: nickname.to_string(),
avatar_path: None,
created_at: now,
})
}
pub fn get_user_by_email(&self, email: &str) -> Result<Option<User>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?1")?;
let mut rows = stmt.query_map(params![email], |row| {
Ok(User {
id: row.get(0)?,
email: row.get(1)?,
phone: row.get(2)?,
nickname: row.get(3)?,
avatar_path: row.get(4)?,
created_at: row.get(5)?,
})
})?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn get_user_by_phone(&self, phone: &str) -> Result<Option<User>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?1")?;
let mut rows = stmt.query_map(params![phone], |row| {
Ok(User {
id: row.get(0)?,
email: row.get(1)?,
phone: row.get(2)?,
nickname: row.get(3)?,
avatar_path: row.get(4)?,
created_at: row.get(5)?,
})
})?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
// ── Clothing CRUD ──
pub fn create_clothing(&self, item: &ClothingItem) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let seasons_json = serde_json::to_string(&item.seasons)?;
let occasions_json = serde_json::to_string(&item.occasions)?;
conn.execute(
"INSERT INTO clothing_items (id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)",
params![
item.id, item.user_id, item.name, item.category, item.color,
item.brand, item.material, seasons_json, occasions_json,
item.formality, item.thickness, item.image_path,
item.purchase_date, item.purchase_price, item.last_cleaned,
item.clean_method, item.wear_count, item.last_worn,
item.is_favorite, item.created_at, item.updated_at
],
)?;
Ok(())
}
pub fn list_clothing(&self, user_id: &str, category: Option<&str>) -> Result<Vec<ClothingItem>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let base = "SELECT id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at FROM clothing_items WHERE user_id = ?1";
let (sql, params_clause): (String, Vec<Box<dyn duckdb::ToSql>>) = match category {
Some(cat) => (
format!("{} AND category = ?2 ORDER BY created_at DESC", base),
vec![Box::new(cat.to_string())],
),
None => (
format!("{} ORDER BY created_at DESC", base),
Vec::new(),
),
};
let mut stmt = conn.prepare(&sql)?;
// Build unified param list: user_id + optional category
let mut all_params: Vec<Box<dyn duckdb::ToSql>> = Vec::with_capacity(1 + params_clause.len());
all_params.push(Box::new(user_id.to_string()));
all_params.extend(params_clause);
let param_refs: Vec<&dyn duckdb::ToSql> = all_params.iter().map(|b| b.as_ref()).collect();
let mut rows = stmt.query(param_refs.as_slice())?;
let mut items = Vec::new();
while let Some(row) = rows.next()? {
items.push(Self::map_clothing_row(row)?);
}
Ok(items)
}
pub fn get_clothing(&self, id: &str) -> Result<Option<ClothingItem>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at FROM clothing_items WHERE id = ?1")?;
let mut rows = stmt.query_map(params![id], Self::map_clothing_row)?;
match rows.next() {
Some(row) => Ok(Some(row?)),
None => Ok(None),
}
}
pub fn update_clothing(&self, id: &str, name: &str, category: &str, color: &str) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE clothing_items SET name = ?1, category = ?2, color = ?3, updated_at = ?4 WHERE id = ?5",
params![name, category, color, now, id],
)?;
Ok(())
}
pub fn delete_clothing(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM clothing_items WHERE id = ?1", params![id])?;
Ok(())
}
fn map_clothing_row(row: &duckdb::Row) -> Result<ClothingItem, duckdb::Error> {
let seasons_str: String = row.get(7)?;
let occasions_str: String = row.get(8)?;
Ok(ClothingItem {
id: row.get(0)?,
user_id: row.get(1)?,
name: row.get(2)?,
category: row.get(3)?,
color: row.get(4)?,
brand: row.get(5)?,
material: row.get(6)?,
seasons: serde_json::from_str(&seasons_str).unwrap_or_default(),
occasions: serde_json::from_str(&occasions_str).unwrap_or_default(),
formality: row.get(9)?,
thickness: row.get(10)?,
image_path: row.get(11)?,
purchase_date: row.get(12)?,
purchase_price: row.get(13)?,
last_cleaned: row.get(14)?,
clean_method: row.get(15)?,
wear_count: row.get(16)?,
last_worn: row.get(17)?,
is_favorite: row.get(18)?,
created_at: row.get(19)?,
updated_at: row.get(20)?,
})
}
// ── Outfit CRUD ──
pub fn create_outfit(&self, outfit: &Outfit) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let item_ids_json = serde_json::to_string(&outfit.item_ids)?;
conn.execute(
"INSERT INTO outfits (id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
params![
outfit.id, outfit.user_id, outfit.name, item_ids_json,
outfit.occasion, outfit.season, outfit.match_score,
outfit.ai_analysis, outfit.is_saved, outfit.created_at
],
)?;
Ok(())
}
pub fn list_outfits(&self, user_id: &str) -> Result<Vec<Outfit>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at FROM outfits WHERE user_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map(params![user_id], |row| {
let item_ids_str: String = row.get(3)?;
Ok(Outfit {
id: row.get(0)?,
user_id: row.get(1)?,
name: row.get(2)?,
item_ids: serde_json::from_str(&item_ids_str).unwrap_or_default(),
occasion: row.get(4)?,
season: row.get(5)?,
match_score: row.get(6)?,
ai_analysis: row.get(7)?,
is_saved: row.get(8)?,
created_at: row.get(9)?,
})
})?;
let mut outfits = Vec::new();
for row in rows {
outfits.push(row?);
}
Ok(outfits)
}
// ── Daily Record CRUD ──
pub fn upsert_daily_record(&self, record: &DailyRecord) -> Result<(), Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO daily_records (id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)
ON CONFLICT (user_id, date) DO UPDATE SET outfit_id = ?4, weather_temp = ?5, weather_condition = ?6, notes = ?7",
params![
record.id, record.user_id, record.date, record.outfit_id,
record.weather_temp, record.weather_condition, record.notes, record.created_at
],
)?;
Ok(())
}
pub fn list_daily_records(&self, user_id: &str, limit: i32) -> Result<Vec<DailyRecord>, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at FROM daily_records WHERE user_id = ?1 ORDER BY date DESC LIMIT ?2")?;
let rows = stmt.query_map(params![user_id, limit], |row| {
Ok(DailyRecord {
id: row.get(0)?,
user_id: row.get(1)?,
date: row.get(2)?,
outfit_id: row.get(3)?,
weather_temp: row.get(4)?,
weather_condition: row.get(5)?,
notes: row.get(6)?,
created_at: row.get(7)?,
})
})?;
let mut records = Vec::new();
for row in rows {
records.push(row?);
}
Ok(records)
}
// ── Stats ──
pub fn get_stats(&self, user_id: &str) -> Result<WardrobeStats, Box<dyn std::error::Error>> {
let conn = self.conn.lock().unwrap();
let total_items: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1", params![user_id], |r| r.get(0)
)?;
let total_outfits: i32 = conn.query_row(
"SELECT COUNT(*) FROM outfits WHERE user_id = ?1", params![user_id], |r| r.get(0)
)?;
let favorite_count: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND is_favorite = TRUE", params![user_id], |r| r.get(0)
)?;
let unworn_30d: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND (last_worn IS NULL OR last_worn < strftime('%Y-%m-%d', 'now', '-30 days'))",
params![user_id], |r| r.get(0)
)?;
let needs_cleaning: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND (last_cleaned IS NULL OR last_cleaned < strftime('%Y-%m-%d', 'now', '-30 days'))",
params![user_id], |r| r.get(0)
)?;
let utilization_rate = if total_items > 0 {
((total_items - unworn_30d) as f64 / total_items as f64 * 100.0).round()
} else {
0.0
};
let mut category_counts = std::collections::HashMap::new();
let cats = ["top", "bottom", "outer", "dress", "shoes", "accessory"];
for cat in cats {
let count: i32 = conn.query_row(
"SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND category = ?2",
params![user_id, cat],
|r| r.get(0),
)?;
category_counts.insert(cat.to_string(), count);
}
Ok(WardrobeStats {
total_items,
total_outfits,
utilization_rate,
category_counts,
favorite_count,
unworn_30d,
needs_cleaning,
})
}
pub fn db_path(&self) -> &PathBuf {
&self.path
}
}
+298
View File
@@ -0,0 +1,298 @@
mod auth;
mod db;
mod mcp;
mod models;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use log::info;
use tauri::Manager;
use auth::AuthManager;
use db::Database;
use mcp::McpService;
use models::*;
struct AppState {
db: Arc<Database>,
auth: Arc<AuthManager>,
mcp: Arc<McpService>,
shutdown_flag: Arc<AtomicBool>,
}
// ══════════════════════════════════════════════════
// Tauri IPC Commands
// ══════════════════════════════════════════════════
#[tauri::command]
fn send_code(state: tauri::State<AppState>, target: String) -> Result<String, String> {
state.auth.send_verification(&target)
}
#[tauri::command]
fn register(
state: tauri::State<AppState>,
email: Option<String>,
phone: Option<String>,
code: String,
nickname: String,
) -> Result<AuthResult, String> {
state.auth.register(&state.db, email.as_deref(), phone.as_deref(), &code, &nickname)
}
#[tauri::command]
fn login(
state: tauri::State<AppState>,
email: Option<String>,
phone: Option<String>,
code: String,
) -> Result<AuthResult, String> {
state.auth.login(&state.db, email.as_deref(), phone.as_deref(), &code)
}
#[tauri::command]
fn add_clothing(
state: tauri::State<AppState>,
user_id: String,
name: String,
category: String,
color: String,
brand: Option<String>,
seasons: Option<Vec<String>>,
occasions: Option<Vec<String>>,
) -> Result<ClothingItem, String> {
let now = chrono::Utc::now().to_rfc3339();
let item = ClothingItem {
id: uuid::Uuid::new_v4().to_string(),
user_id,
name,
category,
color,
brand,
material: None,
seasons: seasons.unwrap_or_default(),
occasions: occasions.unwrap_or_default(),
formality: 50,
thickness: 50,
image_path: None,
purchase_date: None,
purchase_price: None,
last_cleaned: None,
clean_method: None,
wear_count: 0,
last_worn: None,
is_favorite: false,
created_at: now.clone(),
updated_at: now,
};
state.db.create_clothing(&item).map_err(|e| e.to_string())?;
Ok(item)
}
#[tauri::command]
fn list_clothing(
state: tauri::State<AppState>,
user_id: String,
category: Option<String>,
) -> Result<Vec<ClothingItem>, String> {
state.db.list_clothing(&user_id, category.as_deref()).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_clothing(state: tauri::State<AppState>, id: String) -> Result<Option<ClothingItem>, String> {
state.db.get_clothing(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn update_clothing(
state: tauri::State<AppState>,
id: String,
name: String,
category: String,
color: String,
) -> Result<(), String> {
state.db.update_clothing(&id, &name, &category, &color).map_err(|e| e.to_string())
}
#[tauri::command]
fn delete_clothing(state: tauri::State<AppState>, id: String) -> Result<(), String> {
state.db.delete_clothing(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn add_outfit(
state: tauri::State<AppState>,
user_id: String,
name: String,
item_ids: Vec<String>,
occasion: Option<String>,
season: Option<String>,
) -> Result<Outfit, String> {
let now = chrono::Utc::now().to_rfc3339();
let outfit = Outfit {
id: uuid::Uuid::new_v4().to_string(),
user_id,
name,
item_ids,
occasion,
season,
match_score: None,
ai_analysis: None,
is_saved: false,
created_at: now,
};
state.db.create_outfit(&outfit).map_err(|e| e.to_string())?;
Ok(outfit)
}
#[tauri::command]
fn list_outfits(state: tauri::State<AppState>, user_id: String) -> Result<Vec<Outfit>, String> {
state.db.list_outfits(&user_id).map_err(|e| e.to_string())
}
#[tauri::command]
fn record_daily(
state: tauri::State<AppState>,
user_id: String,
date: String,
outfit_id: Option<String>,
notes: Option<String>,
) -> Result<DailyRecord, String> {
let now = chrono::Utc::now().to_rfc3339();
let record = DailyRecord {
id: uuid::Uuid::new_v4().to_string(),
user_id,
date,
outfit_id,
weather_temp: None,
weather_condition: None,
notes,
created_at: now,
};
state.db.upsert_daily_record(&record).map_err(|e| e.to_string())?;
Ok(record)
}
#[tauri::command]
fn list_daily_records(
state: tauri::State<AppState>,
user_id: String,
limit: Option<i32>,
) -> Result<Vec<DailyRecord>, String> {
state.db.list_daily_records(&user_id, limit.unwrap_or(30)).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_stats(state: tauri::State<AppState>, user_id: String) -> Result<WardrobeStats, String> {
state.db.get_stats(&user_id).map_err(|e| e.to_string())
}
// ── MCP Commands ──
#[tauri::command]
fn mcp_list_tools(state: tauri::State<AppState>) -> Result<Vec<mcp::McpTool>, String> {
Ok(state.mcp.list_tools())
}
#[tauri::command]
fn mcp_call_tool(
state: tauri::State<AppState>,
tool_name: String,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
state.mcp.call_tool(&tool_name, &args)
}
// ══════════════════════════════════════════════════
// Main (桌面 + Android 共用)
// ══════════════════════════════════════════════════
// 必须用 tauri_plugin_hotswap::init 包装 generate_context,
// 返回 (插件实例, 新的 context), 并把插件注入 Builder。
// Android 上通过 mobile_entry_point 从这里启动。
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::init();
info!("Wardrobe starting...");
let shutdown_flag = Arc::new(AtomicBool::new(false));
let ctrlc_flag = shutdown_flag.clone();
ctrlc::set_handler(move || {
info!("Ctrl+C received, shutting down gracefully...");
ctrlc_flag.store(true, Ordering::SeqCst);
})
.expect("Error setting Ctrl-C handler");
// 初始化 hotswap 插件 (OTA 升级), 返回改装后的 context
let context = tauri::generate_context!();
let (hotswap, context) = match tauri_plugin_hotswap::init(context) {
Ok(ok) => ok,
Err(e) => {
eprintln!("failed to initialize tauri-plugin-hotswap: {:?}", e);
std::process::exit(1);
}
};
let app = tauri::Builder::default()
.plugin(hotswap)
.plugin(tauri_plugin_shell::init())
.setup(|app| {
info!("Initializing database...");
let data_dir = app.path().app_data_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let database = match Database::new(&data_dir) {
Ok(db) => Arc::new(db),
Err(e) => {
eprintln!("Failed to initialize database: {}", e);
std::process::exit(1);
}
};
let auth = Arc::new(AuthManager::new());
let mcp = Arc::new(McpService::new(database.clone()));
app.manage(AppState {
db: database,
auth,
mcp,
shutdown_flag: shutdown_flag.clone(),
});
info!("Wardrobe app initialized, data dir: {:?}", data_dir);
Ok(())
})
.on_window_event(move |_window, event| {
if let tauri::WindowEvent::Destroyed = event {
info!("Window destroyed, shutting down gracefully...");
shutdown_flag.store(true, Ordering::SeqCst);
}
})
.invoke_handler(tauri::generate_handler![
send_code,
register,
login,
add_clothing,
list_clothing,
get_clothing,
update_clothing,
delete_clothing,
add_outfit,
list_outfits,
record_daily,
list_daily_records,
get_stats,
mcp_list_tools,
mcp_call_tool,
])
.build(context)
.expect("error while building tauri application");
info!("Running Tauri application");
app.run(|_app, _event| {
// Additional lifecycle handling can be added here
});
info!("Wardrobe shut down gracefully");
}
+7
View File
@@ -0,0 +1,7 @@
// Windows / Linux / macOS 桌面端入口
// Android 不走这里, 走 .lib crate (mobile_entry_point)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
wardrobe_lib::run();
}
+205
View File
@@ -0,0 +1,205 @@
use std::sync::Arc;
use log::info;
use crate::db::Database;
use crate::models::*;
pub struct McpService {
db: Arc<Database>,
}
impl McpService {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
pub fn list_tools(&self) -> Vec<McpTool> {
vec![
McpTool {
name: "list_clothing".to_string(),
description: "获取用户衣橱中的所有衣物列表".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"category":{"type":"string"}},"required":["user_id"]}"#.to_string(),
},
McpTool {
name: "get_clothing".to_string(),
description: "根据ID获取单件衣物详情".to_string(),
input_schema: r#"{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}"#.to_string(),
},
McpTool {
name: "add_clothing".to_string(),
description: "添加新衣物到衣橱".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"},"brand":{"type":"string"},"seasons":{"type":"array","items":{"type":"string"}},"occasions":{"type":"array","items":{"type":"string"}}},"required":["user_id","name","category","color"]}"#.to_string(),
},
McpTool {
name: "update_clothing".to_string(),
description: "更新衣物信息".to_string(),
input_schema: r#"{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"}},"required":["id"]}"#.to_string(),
},
McpTool {
name: "delete_clothing".to_string(),
description: "从衣橱中删除衣物".to_string(),
input_schema: r#"{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}"#.to_string(),
},
McpTool {
name: "list_outfits".to_string(),
description: "获取用户的所有搭配方案".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"}},"required":["user_id"]}"#.to_string(),
},
McpTool {
name: "add_outfit".to_string(),
description: "创建新的搭配方案".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"name":{"type":"string"},"item_ids":{"type":"array","items":{"type":"string"}},"occasion":{"type":"string"},"season":{"type":"string"}},"required":["user_id","name","item_ids"]}"#.to_string(),
},
McpTool {
name: "get_wardrobe_stats".to_string(),
description: "获取衣橱统计数据(总数、利用率、分类统计等)".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"}},"required":["user_id"]}"#.to_string(),
},
McpTool {
name: "record_daily_outfit".to_string(),
description: "记录每日穿搭".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"date":{"type":"string"},"outfit_id":{"type":"string"},"notes":{"type":"string"}},"required":["user_id","date"]}"#.to_string(),
},
McpTool {
name: "list_daily_records".to_string(),
description: "获取穿搭日历记录".to_string(),
input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"limit":{"type":"integer"}},"required":["user_id"]}"#.to_string(),
},
]
}
pub fn call_tool(&self, name: &str, args: &serde_json::Value) -> Result<serde_json::Value, String> {
info!("MCP tool call: {}", name);
match name {
"list_clothing" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let category = args["category"].as_str();
let items = self.db.list_clothing(user_id, category).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(items).unwrap())
}
"get_clothing" => {
let id = args["id"].as_str().ok_or("missing id")?;
match self.db.get_clothing(id).map_err(|e| e.to_string())? {
Some(item) => Ok(serde_json::to_value(item).unwrap()),
None => Ok(serde_json::json!({"error": "衣物不存在"})),
}
}
"add_clothing" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let name = args["name"].as_str().ok_or("missing name")?;
let category = args["category"].as_str().ok_or("missing category")?;
let color = args["color"].as_str().ok_or("missing color")?;
let brand = args["brand"].as_str();
let seasons: Vec<String> = args["seasons"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let occasions: Vec<String> = args["occasions"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
let item = ClothingItem {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
name: name.to_string(),
category: category.to_string(),
color: color.to_string(),
brand: brand.map(String::from),
material: None,
seasons,
occasions,
formality: args["formality"].as_i64().unwrap_or(50) as i32,
thickness: args["thickness"].as_i64().unwrap_or(50) as i32,
image_path: None,
purchase_date: None,
purchase_price: None,
last_cleaned: None,
clean_method: None,
wear_count: 0,
last_worn: None,
is_favorite: false,
created_at: now.clone(),
updated_at: now,
};
self.db.create_clothing(&item).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(item).unwrap())
}
"update_clothing" => {
let id = args["id"].as_str().ok_or("missing id")?;
let name = args["name"].as_str().unwrap_or("");
let category = args["category"].as_str().unwrap_or("");
let color = args["color"].as_str().unwrap_or("");
self.db.update_clothing(id, name, category, color).map_err(|e| e.to_string())?;
Ok(serde_json::json!({"success": true}))
}
"delete_clothing" => {
let id = args["id"].as_str().ok_or("missing id")?;
self.db.delete_clothing(id).map_err(|e| e.to_string())?;
Ok(serde_json::json!({"success": true}))
}
"list_outfits" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let outfits = self.db.list_outfits(user_id).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(outfits).unwrap())
}
"add_outfit" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let name = args["name"].as_str().ok_or("missing name")?;
let item_ids: Vec<String> = args["item_ids"].as_array()
.ok_or("missing item_ids")?
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
let now = chrono::Utc::now().to_rfc3339();
let outfit = Outfit {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
name: name.to_string(),
item_ids,
occasion: args["occasion"].as_str().map(String::from),
season: args["season"].as_str().map(String::from),
match_score: None,
ai_analysis: None,
is_saved: false,
created_at: now,
};
self.db.create_outfit(&outfit).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(outfit).unwrap())
}
"get_wardrobe_stats" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let stats = self.db.get_stats(user_id).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(stats).unwrap())
}
"record_daily_outfit" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let date = args["date"].as_str().ok_or("missing date")?;
let now = chrono::Utc::now().to_rfc3339();
let record = DailyRecord {
id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
date: date.to_string(),
outfit_id: args["outfit_id"].as_str().map(String::from),
weather_temp: args["weather_temp"].as_f64(),
weather_condition: args["weather_condition"].as_str().map(String::from),
notes: args["notes"].as_str().map(String::from),
created_at: now,
};
self.db.upsert_daily_record(&record).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(record).unwrap())
}
"list_daily_records" => {
let user_id = args["user_id"].as_str().ok_or("missing user_id")?;
let limit = args["limit"].as_i64().unwrap_or(30) as i32;
let records = self.db.list_daily_records(user_id, limit).map_err(|e| e.to_string())?;
Ok(serde_json::to_value(records).unwrap())
}
_ => Err(format!("Unknown tool: {}", name)),
}
}
}
#[derive(serde::Serialize)]
pub struct McpTool {
pub name: String,
pub description: String,
pub input_schema: String,
}
+89
View File
@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClothingItem {
pub id: String,
pub user_id: String,
pub name: String,
pub category: String,
pub color: String,
pub brand: Option<String>,
pub material: Option<String>,
pub seasons: Vec<String>,
pub occasions: Vec<String>,
pub formality: i32,
pub thickness: i32,
pub image_path: Option<String>,
pub purchase_date: Option<String>,
pub purchase_price: Option<f64>,
pub last_cleaned: Option<String>,
pub clean_method: Option<String>,
pub wear_count: i32,
pub last_worn: Option<String>,
pub is_favorite: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Outfit {
pub id: String,
pub user_id: String,
pub name: String,
pub item_ids: Vec<String>,
pub occasion: Option<String>,
pub season: Option<String>,
pub match_score: Option<i32>,
pub ai_analysis: Option<String>,
pub is_saved: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyRecord {
pub id: String,
pub user_id: String,
pub date: String,
pub outfit_id: Option<String>,
pub weather_temp: Option<f64>,
pub weather_condition: Option<String>,
pub notes: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub email: Option<String>,
pub phone: Option<String>,
pub nickname: String,
pub avatar_path: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationCode {
pub target: String,
pub code: String,
pub created_at: i64,
pub expires_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResult {
pub success: bool,
pub message: String,
pub user: Option<User>,
pub token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WardrobeStats {
pub total_items: i32,
pub total_outfits: i32,
pub utilization_rate: f64,
pub category_counts: std::collections::HashMap<String, i32>,
pub favorite_count: i32,
pub unworn_30d: i32,
pub needs_cleaning: i32,
}
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Wardrobe",
"version": "0.1.0",
"identifier": "com.wardrobe.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Wardrobe - 衣物管理",
"width": 390,
"height": 844,
"minWidth": 360,
"minHeight": 640,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"shell": {
"open": true
},
"hotswap": {
"endpoint": "http://bh.vps.honor3.com:3002/api/ota/check/{{current_sequence}}",
"pubkey": "RWTHU7sECNXhIJY+dp+plthZMsQ2oGdboaKxuRA2QaqbCq3tkKGiKsYl",
"require_https": false,
"headers": {}
}
}
}
+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
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
const host = process.env.TAURI_DEV_HOST;
function buildId() {
// YYYYMMDD.HHmmss: 每次构建生成新的 BUILD_ID, 用于 OTA/web 升级比对
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return (
[d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate())].join("") +
"." +
[pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds())].join("")
);
}
export default defineConfig(async () => {
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
return {
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
// 编译期常量: 版本 + 构建号, 供 src/version.ts 使用
define: {
__BUILD_ID__: JSON.stringify(buildId()),
__APP_VERSION__: JSON.stringify(pkg.version || "0.0.0"),
},
};
});