Skip to content

Crystal ERP 2.0 — 架构更新实施计划

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 根据业务需求更新 Crystal ERP 架构:分离原石/加工品库存、简化 Catawiki 集成、新增 RBAC 登录系统

Architecture:

  • 库存分为独立模块:Raw Crystals(原石)和 Crafted Pieces(加工品),成本独立计算
  • Catawiki 作为普通平台接入,无需特殊拍卖逻辑
  • Supabase Auth + RLS 实现员工/老板权限分离

Tech Stack: Vue 3 + Supabase (Auth + PostgreSQL) + Vercel


前置条件

  • Crystal ERP 基础代码已存在(apps/erp/)
  • Supabase 项目已配置
  • Vikunja Project #8 已更新任务

Task 1: 数据库 Schema 更新

Files:

  • Modify: apps/erp/supabase/migrations/YYYYMMDDHHMMSS_schema_update.sql
  • Create: apps/erp/docs/database-schema-v2.md

Step 1: 新增加工品表

sql
-- crafted_items 表(加工品独立管理)
create table crafted_items (
  id uuid primary key default gen_random_uuid(),
  sku text unique not null,
  name text not null,
  description text,
  
  -- 成本(包含原石成本 + 工时 + 辅料)
  material_cost decimal(10,2),      -- 原石成本
  labor_cost decimal(10,2),         -- 工时成本
  accessory_cost decimal(10,2),     -- 辅料成本
  total_cost decimal(10,2),         -- 总成本 = material + labor + accessory
  
  -- 定价
  list_price decimal(10,2),
  list_price_jpy decimal(10,2),
  currency text default 'JPY',
  
  -- 状态
  status text default 'draft',      -- draft, listed, sold, archived
  platform_id uuid references platforms(id),
  
  -- 图片
  images jsonb default '[]',
  
  -- 元数据
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- 为 RLS 准备
alter table crafted_items enable row level security;

Step 2: 更新原石表(添加权限控制字段)

sql
-- inventory_items 表新增敏感数据标记
alter table inventory_items add column if not exists 
  sensitive_data jsonb default '{}'::jsonb;

-- 将成本相关字段标记为敏感
-- 注意:现有字段保留,通过 RLS 控制访问

Step 3: 新增用户角色表

sql
-- 用户角色扩展(基于 Supabase Auth)
create table user_profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  email text not null,
  display_name text,
  role text default 'staff',        -- owner, manager, staff
  is_active boolean default true,
  created_at timestamptz default now()
);

-- RLS:用户只能看到自己的 profile
alter table user_profiles enable row level security;

-- 插入老板账号(手动执行)
-- insert into user_profiles (id, email, display_name, role) 
-- values ('owner-uuid', '[email protected]', 'Owner', 'owner');

Step 4: 设置 RLS 策略

sql
-- inventory_items: 员工只能看到非敏感字段
-- 老板/经理可以看到全部
create policy "inventory_items_owner_access"
  on inventory_items for select
  using (
    exists (
      select 1 from user_profiles 
      where id = auth.uid() and role in ('owner', 'manager')
    )
  );

create policy "inventory_items_staff_access"
  on inventory_items for select
  using (
    exists (
      select 1 from user_profiles 
      where id = auth.uid() and role = 'staff'
    )
  )
  with check (true);  -- staff 只能看到公开字段,需要配合视图

Step 5: 运行迁移

bash
cd apps/erp
supabase db push

Expected: 迁移成功,无错误

Step 6: Commit

bash
git add supabase/migrations/
git commit -m "db: add crafted_items, user_profiles, RLS policies"

Task 2: 新增登录系统

Files:

  • Create: apps/erp/src/views/Login.vue
  • Create: apps/erp/src/composables/useAuth.ts
  • Modify: apps/erp/src/App.vue (添加登录状态检查)
  • Modify: apps/erp/src/router/index.ts (添加登录守卫)

Step 1: 创建 useAuth composable

typescript
// src/composables/useAuth.ts
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase'

export type UserRole = 'owner' | 'manager' | 'staff'

export interface UserProfile {
  id: string
  email: string
  display_name: string
  role: UserRole
}

const user = ref<UserProfile | null>(null)
const isAuthenticated = computed(() => !!user.value)
const isOwner = computed(() => user.value?.role === 'owner')
const isManager = computed(() => user.value?.role === 'manager')
const canViewSensitiveData = computed(() => 
  user.value?.role === 'owner' || user.value?.role === 'manager'
)

export function useAuth() {
  const signIn = async (email: string, password: string) => {
    const { data, error } = await supabase.auth.signInWithPassword({
      email,
      password
    })
    if (error) throw error
    await fetchProfile(data.user.id)
    return data
  }

  const signOut = async () => {
    await supabase.auth.signOut()
    user.value = null
  }

  const fetchProfile = async (userId: string) => {
    const { data, error } = await supabase
      .from('user_profiles')
      .select('*')
      .eq('id', userId)
      .single()
    
    if (error) throw error
    user.value = data
  }

  const initAuth = async () => {
    const { data: { session } } = await supabase.auth.getSession()
    if (session?.user) {
      await fetchProfile(session.user.id)
    }
  }

  return {
    user,
    isAuthenticated,
    isOwner,
    isManager,
    canViewSensitiveData,
    signIn,
    signOut,
    initAuth
  }
}

Step 2: 创建 Login 页面

vue
<!-- src/views/Login.vue -->
<template>
  <div class="min-h-screen flex items-center justify-center bg-base-200">
    <div class="card w-96 bg-base-100 shadow-xl">
      <div class="card-body">
        <h2 class="card-title justify-center mb-4">Crystal ERP 登录</h2>
        
        <form @submit.prevent="handleSubmit">
          <div class="form-control">
            <label class="label">
              <span class="label-text">邮箱</span>
            </label>
            <input 
              v-model="email" 
              type="email" 
              class="input input-bordered" 
              required
            />
          </div>
          
          <div class="form-control mt-2">
            <label class="label">
              <span class="label-text">密码</span>
            </label>
            <input 
              v-model="password" 
              type="password" 
              class="input input-bordered" 
              required
            />
          </div>
          
          <div v-if="error" class="alert alert-error mt-4 text-sm">
            {{ error }}
          </div>
          
          <button 
            type="submit" 
            class="btn btn-primary w-full mt-6"
            :disabled="loading"
          >
            <span v-if="loading" class="loading loading-spinner"></span>
            登录
          </button>
        </form>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'

const router = useRouter()
const { signIn } = useAuth()

const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')

const handleSubmit = async () => {
  loading.value = true
  error.value = ''
  
  try {
    await signIn(email.value, password.value)
    router.push('/dashboard')
  } catch (e: any) {
    error.value = e.message || '登录失败'
  } finally {
    loading.value = false
  }
}
</script>

Step 3: 更新路由守卫

typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuth } from '@/composables/useAuth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/views/Login.vue'),
      meta: { public: true }
    },
    {
      path: '/dashboard',
      name: 'Dashboard',
      component: () => import('@/views/Dashboard.vue'),
      meta: { requiresAuth: true }
    },
    // ... 其他路由
  ]
})

router.beforeEach(async (to, from, next) => {
  const { isAuthenticated, initAuth } = useAuth()
  
  // 初始化认证状态
  if (!isAuthenticated.value) {
    await initAuth()
  }
  
  if (to.meta.requiresAuth && !isAuthenticated.value) {
    next('/login')
  } else if (to.meta.public && isAuthenticated.value) {
    next('/dashboard')
  } else {
    next()
  }
})

export default router

Step 4: 测试登录流程

  1. 访问 /login
  2. 输入员工账号登录
  3. 验证跳转到 Dashboard
  4. 清除 localStorage,验证未登录时访问 /dashboard 被重定向到 /login

Step 5: Commit

bash
git add src/composables/useAuth.ts src/views/Login.vue src/router/index.ts
git commit -m "feat(auth): add login system with RBAC"

Task 3: 库存模块分离(原石 vs 加工品)

Files:

  • Create: apps/erp/src/views/inventory/RawInventory.vue
  • Create: apps/erp/src/views/inventory/CraftedInventory.vue
  • Modify: apps/erp/src/views/InventoryList.vue (重构为入口)
  • Create: apps/erp/src/composables/useInventory.ts

Step 1: 创建库存入口页面

vue
<!-- src/views/inventory/InventoryIndex.vue -->
<template>
  <div class="container mx-auto p-4">
    <h1 class="text-2xl font-bold mb-6">库存管理</h1>
    
    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
      <!-- 原石库存卡片 -->
      <router-link to="/inventory/raw" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
        <div class="card-body">
          <div class="flex items-center gap-4">
            <div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
              <span class="text-3xl">💎</span>
            </div>
            <div>
              <h2 class="card-title">原石库存</h2>
              <p class="text-sm text-gray-500">Raw Crystal Inventory</p>
              <p class="text-lg font-bold mt-1">{{ rawCount }} 件</p>
            </div>
          </div>
        </div>
      </router-link>
      
      <!-- 加工品库存卡片 -->
      <router-link to="/inventory/crafted" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
        <div class="card-body">
          <div class="flex items-center gap-4">
            <div class="w-16 h-16 rounded-full bg-secondary/10 flex items-center justify-center">
              <span class="text-3xl">🏺</span>
            </div>
            <div>
              <h2 class="card-title">加工品库存</h2>
              <p class="text-sm text-gray-500">Crafted Pieces</p>
              <p class="text-lg font-bold mt-1">{{ craftedCount }} 件</p>
            </div>
          </div>
        </div>
      </router-link>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase'

const rawCount = ref(0)
const craftedCount = ref(0)

onMounted(async () => {
  const [{ count: raw }, { count: crafted }] = await Promise.all([
    supabase.from('inventory_items').select('*', { count: 'exact', head: true }),
    supabase.from('crafted_items').select('*', { count: 'exact', head: true })
  ])
  rawCount.value = raw || 0
  craftedCount.value = crafted || 0
})
</script>

Step 2: 迁移原有库存列表为原石库存

将现有的 InventoryList.vue 重命名为 RawInventory.vue,路径调整为 /inventory/raw

Step 3: 创建加工品库存页面

vue
<!-- src/views/inventory/CraftedInventory.vue -->
<template>
  <div class="container mx-auto p-4">
    <div class="flex justify-between items-center mb-6">
      <div>
        <h1 class="text-2xl font-bold">加工品库存</h1>
        <p class="text-sm text-gray-500">Crafted Crystal Pieces</p>
      </div>
      <button class="btn btn-primary" @click="showCreateModal = true">
        + 新建加工品
      </button>
    </div>
    
    <!-- 列表展示 -->
    <div class="overflow-x-auto">
      <table class="table table-zebra w-full">
        <thead>
          <tr>
            <th>SKU</th>
            <th>名称</th>
            <th>图片</th>
            <th>总成本</th>
            <th>定价</th>
            <th>状态</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="item in craftedItems" :key="item.id">
            <td class="font-mono">{{ item.sku }}</td>
            <td>{{ item.name }}</td>
            <td>
              <img 
                v-if="item.images?.[0]" 
                :src="item.images[0]" 
                class="w-12 h-12 object-cover rounded"
              />
            </td>
            <td>
              <span v-if="canViewSensitiveData">
                ¥{{ item.total_cost?.toLocaleString() }}
              </span>
              <span v-else class="text-gray-400">—</span>
            </td>
            <td>¥{{ item.list_price_jpy?.toLocaleString() }}</td>
            <td>
              <span class="badge" :class="statusClass(item.status)">
                {{ item.status }}
              </span>
            </td>
            <td>
              <button class="btn btn-sm btn-ghost" @click="viewDetail(item)">
                详情
              </button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/composables/useAuth'

const { canViewSensitiveData } = useAuth()
const craftedItems = ref([])
const showCreateModal = ref(false)

const loadItems = async () => {
  const { data, error } = await supabase
    .from('crafted_items')
    .select('*')
    .order('created_at', { ascending: false })
  
  if (error) throw error
  craftedItems.value = data || []
}

const statusClass = (status: string) => ({
  'draft': 'badge-ghost',
  'listed': 'badge-info',
  'sold': 'badge-success',
  'archived': 'badge-warning'
})[status] || 'badge-ghost'

onMounted(loadItems)
</script>

Step 4: 更新路由

typescript
// 在 router/index.ts 中添加
{
  path: '/inventory',
  name: 'InventoryIndex',
  component: () => import('@/views/inventory/InventoryIndex.vue'),
  meta: { requiresAuth: true }
},
{
  path: '/inventory/raw',
  name: 'RawInventory',
  component: () => import('@/views/inventory/RawInventory.vue'),
  meta: { requiresAuth: true }
},
{
  path: '/inventory/crafted',
  name: 'CraftedInventory',
  component: () => import('@/views/inventory/CraftedInventory.vue'),
  meta: { requiresAuth: true }
}

Step 5: Commit

bash
git add src/views/inventory/
git commit -m "feat(inventory): separate raw crystals and crafted pieces"

Task 4: Catawiki 平台简化接入

Files:

  • Modify: apps/erp/src/views/MasterPlatforms.vue
  • Modify: apps/erp/supabase/seed.sql (如有)

Step 1: 在平台主数据中添加 Catawiki

sql
-- 插入 Catawiki 平台(作为普通平台,无特殊字段)
insert into platforms (code, name, fee_rate, active, created_at)
values (
  'catawiki',
  'Catawiki',
  12.5,  -- 平台手续费百分比(需确认实际费率)
  true,
  now()
);

Step 2: 验证平台列表展示

MasterPlatforms.vue 中确认 Catawiki 显示正常,与其他平台无差异。

Step 3: Commit

bash
git add supabase/seed.sql
git commit -m "feat(platforms): add Catawiki as standard platform"

Task 5: Dashboard 首页与导航重构

Files:

  • Create: apps/erp/src/views/Dashboard.vue
  • Modify: apps/erp/src/components/Layout/Sidebar.vue (或创建新布局)
  • Modify: apps/erp/src/App.vue

Step 1: 创建 Dashboard 页面

vue
<!-- src/views/Dashboard.vue -->
<template>
  <div class="container mx-auto p-4">
    <h1 class="text-2xl font-bold mb-6">经营概览</h1>
    
    <!-- 统计卡片 -->
    <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
      <div class="card bg-base-100 shadow">
        <div class="card-body">
          <p class="text-sm text-gray-500">本月销售额</p>
          <p class="text-2xl font-bold">¥{{ monthlySales.toLocaleString() }}</p>
        </div>
      </div>
      
      <div class="card bg-base-100 shadow">
        <div class="card-body">
          <p class="text-sm text-gray-500">待发货订单</p>
          <p class="text-2xl font-bold">{{ pendingOrders }}</p>
        </div>
      </div>
      
      <div class="card bg-base-100 shadow">
        <div class="card-body">
          <p class="text-sm text-gray-500">在售原石</p>
          <p class="text-2xl font-bold">{{ availableRaw }}</p>
        </div>
      </div>
      
      <div class="card bg-base-100 shadow">
        <div class="card-body">
          <p class="text-sm text-gray-500">在售加工品</p>
          <p class="text-2xl font-bold">{{ availableCrafted }}</p>
        </div>
      </div>
    </div>
    
    <!-- 快捷入口 -->
    <h2 class="text-lg font-bold mb-4">快捷入口</h2>
    <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
      <router-link to="/inventory" class="btn btn-outline h-auto py-4 flex-col">
        <span class="text-2xl mb-2">📦</span>
        <span>库存管理</span>
      </router-link>
      
      <router-link to="/orders" class="btn btn-outline h-auto py-4 flex-col">
        <span class="text-2xl mb-2">🛒</span>
        <span>订单管理</span>
      </router-link>
      
      <router-link to="/customs" class="btn btn-outline h-auto py-4 flex-col">
        <span class="text-2xl mb-2">📋</span>
        <span>报关管理</span>
      </router-link>
      
      <router-link to="/print" class="btn btn-outline h-auto py-4 flex-col">
        <span class="text-2xl mb-2">🏷️</span>
        <span>打印中心</span>
      </router-link>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase'

const monthlySales = ref(0)
const pendingOrders = ref(0)
const availableRaw = ref(0)
const availableCrafted = ref(0)

onMounted(async () => {
  // 并行加载统计数据
  const [
    { count: raw },
    { count: crafted },
    { count: orders }
  ] = await Promise.all([
    supabase.from('inventory_items').select('*', { count: 'exact', head: true }).eq('status', 'available'),
    supabase.from('crafted_items').select('*', { count: 'exact', head: true }).eq('status', 'listed'),
    supabase.from('orders').select('*', { count: 'exact', head: true }).eq('status', 'pending')
  ])
  
  availableRaw.value = raw || 0
  availableCrafted.value = crafted || 0
  pendingOrders.value = orders || 0
})
</script>

Step 2: 更新侧边栏导航

vue
<!-- src/components/Layout/Sidebar.vue -->
<template>
  <aside class="w-64 bg-base-200 min-h-screen p-4">
    <div class="text-xl font-bold mb-6">Crystal ERP</div>
    
    <nav class="space-y-2">
      <router-link to="/dashboard" class="btn btn-ghost w-full justify-start">
        📊 Dashboard
      </router-link>
      
      <div class="divider text-sm">业务模块</div>
      
      <router-link to="/inventory" class="btn btn-ghost w-full justify-start">
        📦 库存管理
      </router-link>
      
      <router-link to="/orders" class="btn btn-ghost w-full justify-start">
        🛒 订单管理
      </router-link>
      
      <router-link to="/customs" class="btn btn-ghost w-full justify-start">
        📋 报关管理
      </router-link>
      
      <div class="divider text-sm">工具</div>
      
      <router-link to="/print" class="btn btn-ghost w-full justify-start">
        🏷️ 打印中心
      </router-link>
      
      <div class="divider text-sm">设置</div>
      
      <router-link to="/platforms" class="btn btn-ghost w-full justify-start">
        🌐 平台管理
      </router-link>
      
      <button class="btn btn-ghost w-full justify-start text-error" @click="handleLogout">
        🚪 退出登录
      </button>
    </nav>
  </aside>
</template>

<script setup lang="ts">
import { useAuth } from '@/composables/useAuth'
import { useRouter } from 'vue-router'

const { signOut } = useAuth()
const router = useRouter()

const handleLogout = async () => {
  await signOut()
  router.push('/login')
}
</script>

Step 3: Commit

bash
git add src/views/Dashboard.vue src/components/Layout/
git commit -m "feat(dashboard): add dashboard homepage and new navigation"

总结

变更清单

  1. 数据库: 新增 crafted_items 表、user_profiles 表,RLS 权限控制
  2. 认证: 完整登录系统,支持 owner/manager/staff 三角色
  3. 库存: 原石/加工品分离,员工看不到成本价
  4. 平台: Catawiki 作为普通平台接入
  5. 导航: Dashboard 首页 + 新侧边栏菜单

后续任务(不在本计划范围)

  • 报关模块详细实现(Phase 1.0+)
  • Etsy API 集成
  • 标签打印 dtpweb 集成

文档更新

  • 更新 Obsidian: project/2026-03-13-crystal-erp-architecture-update.md
  • 更新 Vikunja Project #8 任务状态