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: 新增加工品表
-- 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: 更新原石表(添加权限控制字段)
-- inventory_items 表新增敏感数据标记
alter table inventory_items add column if not exists
sensitive_data jsonb default '{}'::jsonb;
-- 将成本相关字段标记为敏感
-- 注意:现有字段保留,通过 RLS 控制访问Step 3: 新增用户角色表
-- 用户角色扩展(基于 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', 'owner@example.com', 'Owner', 'owner');Step 4: 设置 RLS 策略
-- 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: 运行迁移
cd apps/erp
supabase db pushExpected: 迁移成功,无错误
Step 6: Commit
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
// 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 页面
<!-- 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: 更新路由守卫
// 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 routerStep 4: 测试登录流程
- 访问
/login - 输入员工账号登录
- 验证跳转到 Dashboard
- 清除 localStorage,验证未登录时访问
/dashboard被重定向到/login
Step 5: Commit
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: 创建库存入口页面
<!-- 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: 创建加工品库存页面
<!-- 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: 更新路由
// 在 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
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
-- 插入 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
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 页面
<!-- 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: 更新侧边栏导航
<!-- 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
git add src/views/Dashboard.vue src/components/Layout/
git commit -m "feat(dashboard): add dashboard homepage and new navigation"总结
变更清单
- 数据库: 新增
crafted_items表、user_profiles表,RLS 权限控制 - 认证: 完整登录系统,支持 owner/manager/staff 三角色
- 库存: 原石/加工品分离,员工看不到成本价
- 平台: Catawiki 作为普通平台接入
- 导航: Dashboard 首页 + 新侧边栏菜单
后续任务(不在本计划范围)
- 报关模块详细实现(Phase 1.0+)
- Etsy API 集成
- 标签打印 dtpweb 集成
文档更新
- 更新 Obsidian:
project/2026-03-13-crystal-erp-architecture-update.md - 更新 Vikunja Project #8 任务状态