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/构建阻塞问题
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
[tools]
|
||||
node = "24.21.0"
|
||||
npm = "12.0.2"
|
||||
rust = "stable"
|
||||
|
||||
# 每个项目在 cd 到仓库目录后自动加载以上工具链 (需 mise activate)
|
||||
# 全局配置: ~/.config/mise/config.toml
|
||||
@@ -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
|
||||
@@ -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 分钟,成功后自动失效。
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 475 KiB |
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
@@ -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 指向该目录。')
|
||||
@@ -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 已注入"
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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}"
|
||||
@@ -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)'}`)
|
||||
})
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)" },
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
};
|
||||
});
|
||||