Refactor: 前端重构为 Vue 3 + Vite + Pinia + Cypress E2E #1

Merged
fam merged 1 commits from dev into main 2026-04-06 19:22:20 +00:00
62 changed files with 15035 additions and 8448 deletions

4
.gitignore vendored
View File

@@ -4,3 +4,7 @@ __pycache__/
deploy/kubeconfig
all_recipes_extracted.json
backups/
# Frontend
frontend/node_modules/
frontend/dist/

View File

@@ -1,3 +1,11 @@
FROM node:20-slim AS frontend-build
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
@@ -6,7 +14,7 @@ COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./backend/
COPY frontend/ ./frontend/
COPY --from=frontend-build /build/dist ./frontend/
ENV DB_PATH=/data/oil_calculator.db
ENV FRONTEND_DIR=/app/frontend

86
README.md Normal file
View File

@@ -0,0 +1,86 @@
# doTERRA 精油配方计算器
精油配方成本计算、配方管理、配方卡片导出工具。
## 功能
- **配方查询** - 搜索配方,按名称/精油/标签筛选,分类浏览
- **配方编辑** - 可视化编辑精油成分、滴数,自动计算成本
- **容量换算** - 支持单次/5ml/10ml/30ml 容量,自动稀释比例换算
- **智能粘贴** - 粘贴文本自动识别精油名称和滴数(支持模糊匹配、同音字纠错)
- **配方卡片** - 生成精美配方卡片图片,支持中英双语,品牌水印
- **个人配方** - 保存私人配方,记录使用日记
- **精油库存** - 标记已有精油,自动推荐可做配方
- **收藏系统** - 收藏喜欢的配方,快速访问
- **商业核算** - 项目成本利润分析(企业用户专属)
- **Excel 导出** - 批量导出配方到 Excel 表格
- **多角色权限** - 查看者 / 编辑者 / 高级编辑者 / 管理员
- **Bug 追踪** - 内置问题反馈和追踪系统
## 技术栈
### 前端
- **Vue 3** (Composition API + `<script setup>`)
- **Vite** 构建工具
- **Pinia** 状态管理
- **Vue Router** 路由
- **html2canvas** 配方卡片图片生成
- **ExcelJS** Excel 导出
### 后端
- **FastAPI** (Python)
- **SQLite** 数据库
- **Uvicorn** ASGI 服务器
### 部署
- **Docker** 多阶段构建
- **Kubernetes** (k3s)
- **Traefik** 反向代理 + 自动 TLS
## 快速开始
### 前端开发
```bash
cd frontend
npm install
npm run dev
```
开发服务器启动在 `http://localhost:5173`,自动代理 `/api` 请求到后端。
### 后端开发
```bash
pip install -r backend/requirements.txt
uvicorn backend.main:app --reload --port 8000
```
### Docker 构建
```bash
docker build -t oil-calculator .
docker run -p 8000:8000 -v oil-data:/data oil-calculator
```
访问 `http://localhost:8000`
## 项目结构
```
frontend/src/
router/ # 路由配置
stores/ # Pinia stores (auth, oils, recipes, diary, ui)
composables/ # useApi, useDialog, useSmartPaste, useOilTranslation
components/ # 共享组件 (RecipeCard, TagPicker, LoginModal...)
views/ # 页面 (RecipeSearch, RecipeManager, Inventory...)
assets/ # 全局样式
backend/
main.py # FastAPI 应用 + 所有 API 端点
database.py # SQLite 数据库初始化和迁移
```
## 部署
详见 [doc/deploy.md](doc/deploy.md)。

View File

@@ -2,7 +2,7 @@
## 架构
- **前端**: 静态 HTML由 FastAPI 直接 serve
- **前端**: Vue 3 + Vite + Pinia + Vue Router构建为静态文件由 FastAPI serve
- **后端**: FastAPI + SQLite端口 8000
- **部署**: Kubernetes (k3s) on `oci.euphon.net`
- **域名**: https://oil.oci.euphon.net
@@ -16,20 +16,82 @@
│ ├── database.py # SQLite 数据库操作
│ ├── defaults.json # 默认精油和配方数据(首次启动时 seed
│ └── requirements.txt
├── frontend/
── index.html # 前端页面
├── frontend/ # Vue 3 + Vite 项目
── package.json
│ ├── vite.config.js
│ ├── index.html
│ ├── public/ # 静态资源favicon、PWA icons
│ └── src/
│ ├── main.js # 入口文件
│ ├── App.vue # 根组件
│ ├── router/ # Vue Router 路由配置
│ ├── stores/ # Pinia 状态管理
│ │ ├── auth.js # 认证/用户
│ │ ├── oils.js # 精油价格
│ │ ├── recipes.js # 配方/标签/收藏
│ │ ├── diary.js # 个人配方日记
│ │ └── ui.js # UI 状态
│ ├── composables/ # 组合式函数
│ │ ├── useApi.js # API 请求封装
│ │ ├── useDialog.js # 自定义对话框
│ │ ├── useSmartPaste.js # 智能粘贴解析
│ │ └── useOilTranslation.js # 精油中英翻译
│ ├── components/ # 共享组件
│ │ ├── RecipeCard.vue
│ │ ├── RecipeDetailOverlay.vue
│ │ ├── TagPicker.vue
│ │ ├── LoginModal.vue
│ │ ├── UserMenu.vue
│ │ └── CustomDialog.vue
│ ├── views/ # 页面组件
│ │ ├── RecipeSearch.vue # 配方查询
│ │ ├── RecipeManager.vue # 管理配方
│ │ ├── Inventory.vue # 个人库存
│ │ ├── OilReference.vue # 精油价目
│ │ ├── Projects.vue # 商业核算
│ │ ├── MyDiary.vue # 我的(日记/品牌/账号)
│ │ ├── AuditLog.vue # 操作日志
│ │ ├── BugTracker.vue # Bug 追踪
│ │ └── UserManagement.vue # 用户管<E688B7><E7AEA1>
│ └── assets/
│ └── styles.css # 全局样式
├── deploy/
│ ├── namespace.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── pvc.yaml # 1Gi 持久卷,存放 SQLite 数据库
│ ├── ingress.yaml
│ ├── setup-kubeconfig.sh # 生成受限 kubeconfig 的脚本
│ └── kubeconfig # 受限 kubeconfig仅 oil-calculator namespace
├── Dockerfile
│ ├── setup-kubeconfig.sh
│ └── kubeconfig
├── Dockerfile # 多阶段构建Node → Python
└── doc/deploy.md
```
## 本地开发
```bash
# 前端开发(热更新)
cd frontend
npm install
npm run dev # 默认 http://localhost:5173自动代理 /api 到 :8000
# 后端开发
cd backend
pip install -r requirements.txt
uvicorn backend.main:app --reload --port 8000
```
## 构建
```bash
# 前端构建
cd frontend
npm run build # 输出到 frontend/dist/
# Docker 构建(多阶段:先构建前端,再打包后端)
docker build -t oil-calculator .
```
## 首次部署
已完成,以下为记录。
@@ -63,8 +125,8 @@ kubectl get secret regcred -n guitar -o yaml | sed 's/namespace: guitar/namespac
```bash
# 在本地打包上传
cd "/Users/hera/Hera DOCS/Projects/Essential Oil Formula Cost Calculator"
tar czf /tmp/oil-calc.tar.gz Dockerfile backend/ frontend/ deploy/
cd /path/to/oil
tar czf /tmp/oil-calc.tar.gz Dockerfile backend/ frontend/ deploy/ --exclude='frontend/node_modules' --exclude='frontend/dist'
scp /tmp/oil-calc.tar.gz fam@oci.euphon.net:/tmp/
# SSH 到服务器构建并重启
@@ -112,3 +174,21 @@ KUBECONFIG=deploy/kubeconfig kubectl rollout restart deploy/oil-calculator
| GET | /api/tags | 所有标签 |
| POST | /api/tags | 新增标签 |
| DELETE | /api/tags/{name} | 删除标签 |
| GET | /api/me | 当前用户信息 |
| POST | /api/login | 登录 |
| POST | /api/register | 注册 |
| GET | /api/diary | 个人配方列表 |
| POST | /api/diary | 创建个人配方 |
| PUT | /api/diary/{id} | 更新个人配方 |
| DELETE | /api/diary/{id} | 删除个人配方 |
| GET | /api/favorites | 收藏列表 |
| POST | /api/favorites/{id} | 添加收藏 |
| DELETE | /api/favorites/{id} | 取消收藏 |
| GET | /api/inventory | 个人库存 |
| POST | /api/inventory | 更新库存 |
| GET | /api/projects | 商业项目列表 |
| GET | /api/audit-log | 操作日志 |
| GET | /api/users | 用户列表(管理员)|
| GET | /api/bug-reports | Bug 列表 |
| GET | /api/notifications | 通知列表 |
| GET | /api/version | 服务器版本 |

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
viewportWidth: 1280,
viewportHeight: 800,
video: true,
videoCompression: false,
},
})

View File

@@ -0,0 +1,50 @@
describe('Admin Flow', () => {
beforeEach(() => {
const token = Cypress.env('ADMIN_TOKEN')
if (!token) {
cy.log('ADMIN_TOKEN not set, skipping admin tests')
return
}
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', token)
}
})
// Wait for app to load with admin privileges
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
})
it('shows admin-only tabs', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('操作日志').should('be.visible')
cy.get('.nav-tab').contains('Bug').should('be.visible')
cy.get('.nav-tab').contains('用户管理').should('be.visible')
})
it('can access manage recipes page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('管理配方').click()
cy.url().should('include', '/manage')
})
it('can access audit log page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('操作日志').click()
cy.url().should('include', '/audit')
cy.contains('操作日志').should('be.visible')
})
it('can access user management page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('用户管理').click()
cy.url().should('include', '/users')
cy.contains('用户管理').should('be.visible')
})
it('can access bug tracker page', () => {
if (!Cypress.env('ADMIN_TOKEN')) return
cy.get('.nav-tab').contains('Bug').click()
cy.url().should('include', '/bugs')
cy.contains('Bug').should('be.visible')
})
})

View File

@@ -0,0 +1,357 @@
describe('API CRUD Operations', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
describe('Oils API', () => {
it('creates a new oil', () => {
cy.request({
method: 'POST',
url: '/api/oils',
headers: authHeaders,
body: { name: 'cypress测试油', bottle_price: 100, drop_count: 200 }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists oils including the new one', () => {
cy.request('/api/oils').then(res => {
const found = res.body.find(o => o.name === 'cypress测试油')
expect(found).to.exist
expect(found.bottle_price).to.eq(100)
expect(found.drop_count).to.eq(200)
})
})
it('deletes the test oil', () => {
cy.request({
method: 'DELETE',
url: '/api/oils/' + encodeURIComponent('cypress测试油'),
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
it('verifies oil is deleted', () => {
cy.request('/api/oils').then(res => {
const found = res.body.find(o => o.name === 'cypress测试油')
expect(found).to.not.exist
})
})
})
describe('Recipes API', () => {
let testRecipeId
it('creates a new recipe', () => {
cy.request({
method: 'POST',
url: '/api/recipes',
headers: authHeaders,
body: {
name: 'Cypress测试配方',
note: 'E2E测试用',
ingredients: [
{ oil_name: '薰衣草', drops: 5 },
{ oil_name: '茶树', drops: 3 }
],
tags: []
}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
testRecipeId = res.body.id
expect(testRecipeId).to.be.a('number')
})
})
it('reads the created recipe', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress测试配方')
expect(found).to.exist
expect(found.note).to.eq('E2E测试用')
expect(found.ingredients).to.have.length(2)
testRecipeId = found.id
})
})
it('updates the recipe', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress测试配方')
cy.request({
method: 'PUT',
url: `/api/recipes/${found.id}`,
headers: authHeaders,
body: {
name: 'Cypress更新配方',
note: '已更新',
ingredients: [
{ oil_name: '薰衣草', drops: 10 },
{ oil_name: '乳香', drops: 5 }
],
tags: []
}
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
it('verifies the update', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress更新配方')
expect(found).to.exist
expect(found.note).to.eq('已更新')
expect(found.ingredients).to.have.length(2)
testRecipeId = found.id
})
})
it('deletes the test recipe', () => {
cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress更新配方')
if (found) {
cy.request({
method: 'DELETE',
url: `/api/recipes/${found.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
})
describe('Tags API', () => {
it('creates a new tag', () => {
cy.request({
method: 'POST',
url: '/api/tags',
headers: authHeaders,
body: { name: 'cypress-tag' }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists tags including the new one', () => {
cy.request('/api/tags').then(res => {
expect(res.body).to.include('cypress-tag')
})
})
it('deletes the test tag', () => {
cy.request({
method: 'DELETE',
url: '/api/tags/cypress-tag',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
describe('Diary API', () => {
let diaryId
it('creates a diary entry', () => {
cy.request({
method: 'POST',
url: '/api/diary',
headers: authHeaders,
body: {
name: 'Cypress日记配方',
ingredients: [{ oil: '薰衣草', drops: 3 }],
note: '测试备注'
}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
diaryId = res.body.id
})
})
it('lists diary entries', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
const found = res.body.find(d => d.name === 'Cypress日记配方')
expect(found).to.exist
diaryId = found.id
})
})
it('deletes the diary entry', () => {
cy.request({
url: '/api/diary',
headers: authHeaders
}).then(res => {
const found = res.body.find(d => d.name === 'Cypress日记配方')
if (found) {
cy.request({
method: 'DELETE',
url: `/api/diary/${found.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
})
describe('Favorites API', () => {
it('adds a recipe to favorites', () => {
cy.request('/api/recipes').then(res => {
const recipe = res.body[0]
cy.request({
method: 'POST',
url: `/api/favorites/${recipe.id}`,
headers: authHeaders,
body: {}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
})
it('lists favorites', () => {
cy.request({
url: '/api/favorites',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.gte(1)
})
})
it('removes the favorite', () => {
cy.request('/api/recipes').then(res => {
const recipe = res.body[0]
cy.request({
method: 'DELETE',
url: `/api/favorites/${recipe.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
})
describe('Inventory API', () => {
it('adds oil to inventory', () => {
cy.request({
method: 'POST',
url: '/api/inventory',
headers: authHeaders,
body: { oil_name: '薰衣草' }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('reads inventory', () => {
cy.request({
url: '/api/inventory',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
})
describe('Bug Reports API', () => {
let bugId
it('submits a bug report', () => {
cy.request({
method: 'POST',
url: '/api/bug-report',
headers: authHeaders,
body: { content: 'Cypress E2E测试Bug', priority: 2 }
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists bug reports', () => {
cy.request({
url: '/api/bug-reports',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
const found = res.body.find(b => b.content.includes('Cypress E2E测试Bug'))
expect(found).to.exist
bugId = found.id
})
})
it('deletes the test bug', () => {
cy.request({
url: '/api/bug-reports',
headers: authHeaders
}).then(res => {
const found = res.body.find(b => b.content.includes('Cypress E2E测试Bug'))
if (found) {
cy.request({
method: 'DELETE',
url: `/api/bug-reports/${found.id}`,
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
}
})
})
})
describe('Users API (admin)', () => {
it('lists users', () => {
cy.request({
url: '/api/users',
headers: authHeaders
}).then(res => {
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.gte(1)
const admin = res.body.find(u => u.role === 'admin')
expect(admin).to.exist
})
})
it('cannot access users without auth', () => {
cy.request({
url: '/api/users',
failOnStatusCode: false
}).then(res => {
expect(res.status).to.eq(403)
})
})
})
describe('Audit Log API', () => {
it('fetches audit log', () => {
cy.request({
url: '/api/audit-log?limit=10&offset=0',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
})
describe('Notifications API', () => {
it('fetches notifications', () => {
cy.request({
url: '/api/notifications',
headers: authHeaders
}).then(res => {
expect(res.status).to.eq(200)
})
})
})
})

View File

@@ -0,0 +1,64 @@
describe('API Health Check', () => {
it('GET /api/version returns version', () => {
cy.request('/api/version').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.have.property('version')
})
})
it('GET /api/oils returns oil list', () => {
cy.request('/api/oils').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.gte(1)
const oil = res.body[0]
expect(oil).to.have.property('name')
expect(oil).to.have.property('bottle_price')
expect(oil).to.have.property('drop_count')
})
})
it('GET /api/recipes returns recipe list', () => {
cy.request('/api/recipes').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
if (res.body.length > 0) {
const recipe = res.body[0]
expect(recipe).to.have.property('name')
expect(recipe).to.have.property('ingredients')
}
})
})
it('GET /api/tags returns tags array', () => {
cy.request('/api/tags').then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
})
})
it('GET /api/me returns anonymous user without auth', () => {
cy.request('/api/me').then(res => {
expect(res.status).to.eq(200)
expect(res.body.username).to.eq('anonymous')
expect(res.body.role).to.eq('viewer')
})
})
it('GET /api/me returns authenticated user with valid token', () => {
// Use the admin token from env or skip
const token = Cypress.env('ADMIN_TOKEN')
if (!token) {
cy.log('ADMIN_TOKEN not set, skipping auth test')
return
}
cy.request({
url: '/api/me',
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
expect(res.status).to.eq(200)
expect(res.body.id).to.not.be.null
expect(res.body.username).to.not.eq('anonymous')
})
})
})

View File

@@ -0,0 +1,32 @@
describe('App Loading', () => {
it('loads the home page with header and nav', () => {
cy.visit('/')
cy.get('.app-header').should('be.visible')
cy.contains('doTERRA 配方计算器').should('be.visible')
cy.get('.nav-tabs').should('be.visible')
cy.get('.nav-tab').should('have.length.gte', 4)
})
it('shows the search section by default', () => {
cy.visit('/')
cy.get('.nav-tab').first().should('have.class', 'active')
cy.get('input[placeholder*="搜索"]', { timeout: 8000 }).should('be.visible')
})
it('navigates between public tabs without login', () => {
cy.visit('/')
cy.get('.nav-tab').contains('精油价目').click()
cy.url().should('include', '/oils')
cy.contains('精油价目').should('be.visible')
cy.get('.nav-tab').contains('配方查询').click()
cy.url().should('not.include', '/oils')
})
it('prompts login when accessing protected tabs', () => {
cy.visit('/')
cy.get('.nav-tab').contains('管理配方').click()
// Should show login modal or dialog
cy.get('[class*="overlay"], [class*="login"], [class*="modal"], [class*="dialog"]', { timeout: 3000 }).should('exist')
})
})

View File

@@ -0,0 +1,81 @@
describe('Authentication Flow', () => {
it('shows login button when not authenticated', () => {
cy.visit('/')
cy.contains('登录').should('be.visible')
})
it('opens login modal when clicking login', () => {
cy.visit('/')
cy.contains('登录').click()
cy.get('[class*="overlay"], [class*="modal"], [class*="login"]').should('be.visible')
})
it('login modal has username and password fields', () => {
cy.visit('/')
cy.contains('登录').click()
cy.get('input[placeholder*="用户名"], input[type="text"]').should('exist')
cy.get('input[type="password"]').should('exist')
})
it('shows error for invalid login', () => {
cy.visit('/')
cy.contains('登录').click()
// Try submitting with invalid credentials
cy.get('input[placeholder*="用户名"], input[type="text"]').first().type('nonexistent_user_xyz')
cy.get('input[type="password"]').first().type('wrongpassword')
cy.contains('button', /登录|确定|提交/).click()
// Should show error (alert, toast, or inline message)
cy.wait(1000)
// The modal should still be visible (login failed)
cy.get('[class*="overlay"], [class*="modal"], [class*="login"]').should('exist')
})
it('authenticated user sees their name in header', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.app-header', { timeout: 8000 }).should('be.visible')
cy.contains('Hera').should('be.visible')
})
it('logout clears auth and shows login button', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.contains('Hera', { timeout: 8000 }).should('be.visible')
// Click user name to open menu
cy.contains('Hera').click()
// Click logout
cy.contains(/退出|登出|logout/i).click()
// Should show login button again
cy.contains('登录', { timeout: 5000 }).should('be.visible')
})
it('token from URL param authenticates user', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/?token=' + ADMIN_TOKEN)
// Should authenticate and show user name
cy.contains('Hera', { timeout: 8000 }).should('be.visible')
// Token should be removed from URL
cy.url().should('not.include', 'token=')
})
it('protected tabs become accessible after login', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
cy.get('.nav-tab').contains('管理配方').click()
// Should navigate to manage page, not show login modal
cy.url().should('include', '/manage')
})
})

View File

@@ -0,0 +1,106 @@
// Demo walkthrough for video recording
// Timeline paced to match 90s TTS narration
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
describe('doTERRA 精油配方计算器 - 功能演示', () => {
it('完整功能演示', { defaultCommandTimeout: 15000 }, () => {
// ===== 0:00-0:05 开场:首页加载 =====
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.app-header').should('be.visible')
cy.wait(4500)
// ===== 0:05-0:09 配方卡片列表 =====
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.wait(3500)
// ===== 0:09-0:12 滚动浏览 =====
cy.scrollTo(0, 500, { duration: 1200 })
cy.wait(1500)
cy.scrollTo('top', { duration: 800 })
cy.wait(1000)
// ===== 0:12-0:16 搜索框输入 =====
cy.get('input[placeholder*="搜索"]').click()
cy.wait(800)
cy.get('input[placeholder*="搜索"]').type('薰衣草', { delay: 200 })
cy.wait(2500)
// ===== 0:16-0:20 搜索结果 =====
cy.wait(2000)
cy.get('input[placeholder*="搜索"]').clear()
cy.wait(1500)
// ===== 0:20-0:24 点击配方卡片 =====
cy.get('.recipe-card').first().click()
cy.wait(4000)
// ===== 0:24-0:30 查看详情 =====
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
cy.wait(4500)
cy.get('button').contains(/✕|关闭|←/).first().click()
cy.wait(1500)
// ===== 0:30-0:34 切换精油价目 =====
cy.get('.nav-tab').contains('精油价目').click()
cy.wait(4000)
// ===== 0:34-0:38 搜索精油 =====
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.get('input[placeholder*="搜索精油"]').type('薰衣草', { delay: 200 })
cy.wait(2500)
cy.get('input[placeholder*="搜索精油"]').clear()
cy.wait(1000)
// ===== 0:38-0:42 切换瓶价/滴价 =====
cy.contains('滴价').click()
cy.wait(2000)
cy.contains('瓶价').click()
cy.wait(1500)
// ===== 0:42-0:47 管理配方 =====
cy.get('.nav-tab').contains('管理配方').click()
cy.wait(4500)
// ===== 0:47-0:52 管理页面浏览 =====
cy.scrollTo(0, 300, { duration: 1000 })
cy.wait(2000)
cy.scrollTo('top', { duration: 600 })
cy.wait(2000)
// ===== 0:52-0:56 个人库存 =====
cy.get('.nav-tab').contains('个人库存').click()
cy.wait(4500)
// ===== 0:56-1:00 库存推荐 =====
cy.scrollTo(0, 200, { duration: 600 })
cy.wait(2000)
cy.scrollTo('top', { duration: 400 })
cy.wait(1500)
// ===== 1:00-1:06 操作日志 =====
cy.get('.nav-tab').contains('操作日志').click()
cy.wait(3000)
cy.scrollTo(0, 200, { duration: 600 })
cy.wait(2500)
// ===== 1:06-1:12 Bug 追踪 =====
cy.get('.nav-tab').contains('Bug').click()
cy.wait(5500)
// ===== 1:12-1:18 用户管理 =====
cy.get('.nav-tab').contains('用户管理').click()
cy.wait(5500)
// ===== 1:18-1:22 回到首页 =====
cy.get('.nav-tab').contains('配方查询').click()
cy.wait(3500)
// ===== 1:22-1:30 结束 =====
cy.wait(5000)
})
})

View File

@@ -0,0 +1,87 @@
describe('Favorites System', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
describe('API Level', () => {
let firstRecipeId
before(() => {
cy.request('/api/recipes').then(res => {
firstRecipeId = res.body[0].id
})
})
it('can add a favorite via API', () => {
cy.request({
method: 'POST',
url: `/api/favorites/${firstRecipeId}`,
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
body: {}
}).then(res => {
expect(res.status).to.be.oneOf([200, 201])
})
})
it('lists the favorite', () => {
cy.request({
url: '/api/favorites',
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => {
expect(res.body).to.include(firstRecipeId)
})
})
it('can remove the favorite via API', () => {
cy.request({
method: 'DELETE',
url: `/api/favorites/${firstRecipeId}`,
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => {
expect(res.status).to.eq(200)
})
})
it('favorite is removed from list', () => {
cy.request({
url: '/api/favorites',
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => {
expect(res.body).to.not.include(firstRecipeId)
})
})
})
describe('UI Level', () => {
it('recipe cards have star buttons for logged-in users', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// Stars should be present on cards
cy.get('.recipe-card').first().within(() => {
cy.contains(/★|☆/).should('exist')
})
})
it('clicking star toggles favorite state', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.recipe-card', { timeout: 10000 }).first().within(() => {
cy.contains(/★|☆/).then($star => {
const wasFav = $star.text().includes('★')
$star.trigger('click')
// Star text should have toggled
cy.wait(500)
cy.contains(/★|☆/).invoke('text').should(text => {
if (wasFav) expect(text).to.include('☆')
else expect(text).to.include('★')
})
})
})
})
})
})

View File

@@ -0,0 +1,69 @@
describe('Navigation & Routing', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
it('direct URL /oils loads oil reference page', () => {
cy.visit('/oils')
cy.contains('精油价目').should('be.visible')
})
it('direct URL / loads search page', () => {
cy.visit('/')
cy.get('input[placeholder*="搜索"]', { timeout: 8000 }).should('be.visible')
})
it('unknown route still renders the app', () => {
cy.visit('/nonexistent-page')
cy.get('.app-header').should('be.visible')
cy.get('.nav-tabs').should('be.visible')
})
it('back button works between tabs', () => {
cy.visit('/')
cy.get('.nav-tab').contains('精油价目').click()
cy.url().should('include', '/oils')
cy.go('back')
cy.url().should('not.include', '/oils')
})
it('tab active state tracks after click', () => {
cy.visit('/')
cy.get('.nav-tab').contains('精油价目').click()
cy.get('.nav-tab').contains('精油价目').should('have.class', 'active')
cy.get('.nav-tab').contains('配方查询').should('not.have.class', 'active')
})
it('admin tabs only visible when authenticated', () => {
cy.visit('/')
cy.get('.nav-tab').contains('操作日志').should('not.exist')
cy.get('.nav-tab').contains('用户管理').should('not.exist')
})
it('admin tabs appear after login', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.nav-tab', { timeout: 10000 }).contains('操作日志').should('be.visible')
cy.get('.nav-tab').contains('用户管理').should('be.visible')
})
it('all admin pages are navigable', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
const pages = [
{ tab: '管理配方', url: '/manage' },
{ tab: '个人库存', url: '/inventory' },
{ tab: '精油价目', url: '/oils' },
{ tab: '操作日志', url: '/audit' },
{ tab: '用户管理', url: '/users' },
]
pages.forEach(({ tab, url }) => {
cy.get('.nav-tab').contains(tab).click()
cy.url().should('include', url)
})
})
})

View File

@@ -0,0 +1,107 @@
describe('Oil Data Integrity', () => {
it('all oils have valid prices', () => {
cy.request('/api/oils').then(res => {
res.body.forEach(oil => {
expect(oil.name).to.be.a('string').and.not.be.empty
expect(oil.bottle_price).to.be.a('number').and.be.gte(0)
expect(oil.drop_count).to.be.a('number').and.be.gt(0)
})
})
})
it('common oils exist in the database', () => {
const expected = ['薰衣草', '茶树', '乳香', '柠檬', '椒样薄荷', '椰子油']
cy.request('/api/oils').then(res => {
const names = res.body.map(o => o.name)
expected.forEach(name => {
expect(names).to.include(name)
})
})
})
it('oil price per drop is correctly calculated', () => {
cy.request('/api/oils').then(res => {
res.body.forEach(oil => {
const ppd = oil.bottle_price / oil.drop_count
expect(ppd).to.be.a('number')
expect(ppd).to.be.gte(0)
expect(ppd).to.be.lte(100) // sanity check: no oil costs >100 per drop
})
})
})
it('drop counts match known volume standards', () => {
// Standard doTERRA volumes: 5ml=93, 10ml=186, 15ml=280
const validDropCounts = [46, 93, 160, 186, 280, 2146]
cy.request('/api/oils').then(res => {
const counts = new Set(res.body.map(o => o.drop_count))
// At least some should match standard volumes
const matching = [...counts].filter(c => validDropCounts.includes(c))
expect(matching.length).to.be.gte(1)
})
})
})
describe('Recipe Data Integrity', () => {
it('all recipes have valid structure', () => {
cy.request('/api/recipes').then(res => {
expect(res.body.length).to.be.gte(1)
res.body.forEach(recipe => {
expect(recipe).to.have.property('id')
expect(recipe).to.have.property('name').and.not.be.empty
expect(recipe).to.have.property('ingredients').and.be.an('array')
recipe.ingredients.forEach(ing => {
expect(ing).to.have.property('oil_name').and.not.be.empty
expect(ing).to.have.property('drops').and.be.a('number').and.be.gt(0)
})
})
})
})
it('recipes reference existing oils', () => {
cy.request('/api/oils').then(oilRes => {
const oilNames = new Set(oilRes.body.map(o => o.name))
cy.request('/api/recipes').then(recipeRes => {
let totalMissing = 0
recipeRes.body.forEach(recipe => {
recipe.ingredients.forEach(ing => {
if (!oilNames.has(ing.oil_name)) totalMissing++
})
})
// Allow some missing (discontinued oils), but not too many
const totalIngs = recipeRes.body.reduce((s, r) => s + r.ingredients.length, 0)
const missingRate = totalMissing / totalIngs
expect(missingRate).to.be.lt(0.2) // less than 20% missing
})
})
})
it('no duplicate recipe names', () => {
cy.request('/api/recipes').then(res => {
const names = res.body.map(r => r.name)
const unique = new Set(names)
// Allow some duplicates but flag if many
const dupRate = (names.length - unique.size) / names.length
expect(dupRate).to.be.lt(0.1) // less than 10% duplicates
})
})
it('recipe costs are calculable', () => {
cy.request('/api/oils').then(oilRes => {
const oilPrices = {}
oilRes.body.forEach(o => {
oilPrices[o.name] = o.bottle_price / o.drop_count
})
cy.request('/api/recipes').then(recipeRes => {
recipeRes.body.slice(0, 20).forEach(recipe => {
let cost = 0
recipe.ingredients.forEach(ing => {
cost += (oilPrices[ing.oil_name] || 0) * ing.drops
})
expect(cost).to.be.a('number')
expect(cost).to.be.gte(0)
})
})
})
})
})

View File

@@ -0,0 +1,32 @@
describe('Oil Reference Page', () => {
beforeEach(() => {
cy.visit('/oils')
cy.get('.oil-card, .oils-grid', { timeout: 10000 }).should('exist')
})
it('displays oil grid with items', () => {
cy.contains('精油价目').should('be.visible')
cy.get('.oil-card').should('have.length.gte', 10)
})
it('shows oil name and price on each chip', () => {
cy.get('.oil-card').first().should('contain', '¥')
})
it('filters oils by search', () => {
cy.get('.oil-card').then($chips => {
const initial = $chips.length
cy.get('input[placeholder*="搜索精油"]').type('薰衣草')
cy.wait(300)
cy.get('.oil-card').should('have.length.lt', initial)
})
})
it('toggles between bottle and drop price view', () => {
cy.get('.oil-card').first().invoke('text').then(textBefore => {
cy.contains('滴价').click()
cy.wait(300)
cy.get('.oil-card').first().invoke('text').should('not.eq', textBefore)
})
})
})

View File

@@ -0,0 +1,58 @@
describe('Performance', () => {
it('home page loads within 5 seconds', () => {
const start = Date.now()
cy.visit('/')
cy.get('.recipe-card', { timeout: 5000 }).should('have.length.gte', 1)
cy.then(() => {
const elapsed = Date.now() - start
expect(elapsed).to.be.lt(5000)
})
})
it('API /api/oils responds within 1 second', () => {
const start = Date.now()
cy.request('/api/oils').then(() => {
expect(Date.now() - start).to.be.lt(1000)
})
})
it('API /api/recipes responds within 2 seconds', () => {
const start = Date.now()
cy.request('/api/recipes').then(() => {
expect(Date.now() - start).to.be.lt(2000)
})
})
it('search filtering is near-instant', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
const start = Date.now()
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(300)
cy.get('.recipe-card').should('exist')
cy.then(() => {
expect(Date.now() - start).to.be.lt(2000)
})
})
it('oil reference page loads within 3 seconds', () => {
const start = Date.now()
cy.visit('/oils')
cy.get('.oil-card', { timeout: 3000 }).should('have.length.gte', 1)
cy.then(() => {
expect(Date.now() - start).to.be.lt(3000)
})
})
it('handles 250+ recipes without crashing', () => {
cy.request('/api/recipes').then(res => {
expect(res.body.length).to.be.gte(200)
})
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 10)
// Scroll to trigger lazy loading if any
cy.scrollTo('bottom')
cy.wait(500)
cy.get('.main').should('be.visible')
})
})

View File

@@ -0,0 +1,84 @@
describe('Recipe Detail', () => {
beforeEach(() => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('opens detail overlay when clicking a recipe card', () => {
cy.get('.recipe-card').first().click()
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
})
it('shows recipe name in detail view', () => {
// Get recipe name from card, however it's structured
cy.get('.recipe-card').first().invoke('text').then(cardText => {
cy.get('.recipe-card').first().click()
cy.wait(500)
// The detail view should show some text from the card
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
})
})
it('shows ingredient info with drops', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains('滴').should('exist')
})
it('shows cost with ¥ symbol', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains('¥').should('exist')
})
it('closes detail overlay when clicking close button', () => {
cy.get('.recipe-card').first().click()
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
cy.get('button').contains(/✕|关闭|←/).first().click()
cy.get('.recipe-card').should('be.visible')
})
it('shows action buttons in detail', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
// Should have at least one action button
cy.get('[class*="overlay"] button, [class*="detail"] button').should('have.length.gte', 1)
})
it('shows favorite star', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains(/★|☆|收藏/).should('exist')
})
})
describe('Recipe Detail - Editor (Admin)', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
beforeEach(() => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('shows edit button for admin', () => {
cy.get('.recipe-card').first().click()
cy.wait(500)
cy.contains(/编辑|✏/).should('exist')
})
it('can switch to editor view', () => {
cy.get('.recipe-card').first().click()
cy.contains(/编辑|✏/).first().click()
cy.get('select, input[type="number"], .oil-select, .drops-input').should('exist')
})
it('editor shows save button', () => {
cy.get('.recipe-card').first().click()
cy.contains(/编辑|✏/).first().click()
cy.contains(/保存|💾/).should('exist')
})
})

View File

@@ -0,0 +1,45 @@
describe('Recipe Search', () => {
beforeEach(() => {
cy.visit('/')
// Wait for recipes to load
cy.get('.recipe-card, .empty-state', { timeout: 10000 }).should('exist')
})
it('displays recipe cards in the grid', () => {
cy.get('.recipe-card').should('have.length.gte', 1)
})
it('each recipe card shows name and oils', () => {
cy.get('.recipe-card').first().within(() => {
cy.get('.recipe-card-name').should('not.be.empty')
cy.get('.recipe-card-oils').should('not.be.empty')
})
})
it('filters recipes by search input', () => {
cy.get('.recipe-card').then($cards => {
const initialCount = $cards.length
cy.get('input[placeholder*="搜索"]').type('薰衣草')
// Should filter, possibly fewer results
cy.wait(500)
cy.get('.recipe-card').should('have.length.lte', initialCount)
})
})
it('clears search and restores all recipes', () => {
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(500)
cy.get('.recipe-card').then($filtered => {
const filteredCount = $filtered.length
cy.get('input[placeholder*="搜索"]').clear()
cy.wait(500)
cy.get('.recipe-card').should('have.length.gte', filteredCount)
})
})
it('opens recipe detail when clicking a card', () => {
cy.get('.recipe-card').first().click()
// Should show detail overlay or panel
cy.get('[class*="overlay"], [class*="detail"]', { timeout: 5000 }).should('be.visible')
})
})

View File

@@ -0,0 +1,76 @@
describe('Responsive Design', () => {
describe('Mobile viewport (375x667)', () => {
beforeEach(() => {
cy.viewport(375, 667)
})
it('loads the app on mobile', () => {
cy.visit('/')
cy.get('.app-header').should('be.visible')
cy.contains('doTERRA').should('be.visible')
})
it('nav tabs are scrollable', () => {
cy.visit('/')
cy.get('.nav-tabs').should('have.css', 'overflow-x', 'auto')
})
it('recipe cards stack in single column', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// On mobile, cards should be full width
cy.get('.recipe-card').first().then($card => {
const width = $card.outerWidth()
expect(width).to.be.gte(300)
})
})
it('search input is usable on mobile', () => {
cy.visit('/')
cy.get('input[placeholder*="搜索"]').should('be.visible')
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.get('input[placeholder*="搜索"]').should('have.value', '薰衣草')
})
it('oil reference page works on mobile', () => {
cy.visit('/oils')
cy.contains('精油价目').should('be.visible')
cy.get('.oil-card').should('have.length.gte', 1)
})
})
describe('Tablet viewport (768x1024)', () => {
beforeEach(() => {
cy.viewport(768, 1024)
})
it('loads and shows recipe grid', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('oil grid shows multiple columns', () => {
cy.visit('/oils')
cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
})
})
describe('Wide viewport (1920x1080)', () => {
beforeEach(() => {
cy.viewport(1920, 1080)
})
it('content is centered with max-width', () => {
cy.visit('/')
cy.get('.main').then($main => {
const width = $main.outerWidth()
expect(width).to.be.lte(960)
})
})
it('recipe grid shows multiple columns', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
})
})

View File

@@ -0,0 +1,67 @@
describe('Advanced Search Features', () => {
beforeEach(() => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
it('search input accepts text and app stays stable', () => {
cy.get('input[placeholder*="搜索"]').type('酸痛')
cy.wait(500)
// App should remain functional
cy.get('.main').should('be.visible')
cy.get('input[placeholder*="搜索"]').should('have.value', '酸痛')
})
it('searches by partial recipe name', () => {
cy.get('input[placeholder*="搜索"]').type('安睡')
cy.wait(500)
cy.get('.recipe-card').should('have.length.gte', 0)
})
it('returns fewer results for nonsense query', () => {
cy.get('.recipe-card').then($all => {
const total = $all.length
cy.get('input[placeholder*="搜索"]').type('xyzabcnonexistent')
cy.wait(500)
// Should show empty state or fewer results
cy.get('.recipe-card').should('have.length.lte', total)
})
})
it('search is case-insensitive for latin chars', () => {
cy.get('input[placeholder*="搜索"]').type('doterra')
cy.wait(500)
// Just verify no crash
cy.get('.main').should('be.visible')
})
it('handles special characters in search', () => {
cy.get('input[placeholder*="搜索"]').type('()【】')
cy.wait(300)
cy.get('.main').should('be.visible')
})
it('rapid typing updates results without crash', () => {
const input = cy.get('input[placeholder*="搜索"]')
input.type('薰')
cy.wait(100)
input.type('衣')
cy.wait(100)
input.type('草')
cy.wait(300)
cy.get('.recipe-card').should('have.length.gte', 0)
cy.get('.main').should('be.visible')
})
it('clearing search with button restores all recipes', () => {
cy.get('.recipe-card').then($initial => {
const count = $initial.length
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(300)
// Clear
cy.get('input[placeholder*="搜索"]').clear()
cy.wait(300)
cy.get('.recipe-card').should('have.length', count)
})
})
})

View File

@@ -0,0 +1,33 @@
// Ignore uncaught exceptions from the app (API errors during loading, etc.)
Cypress.on('uncaught:exception', () => false)
// Custom commands for the oil calculator app
// Login as admin via token injection
Cypress.Commands.add('loginAsAdmin', () => {
cy.request('GET', '/api/users').then((res) => {
const admin = res.body.find(u => u.role === 'admin')
if (admin) {
cy.window().then(win => {
win.localStorage.setItem('oil_auth_token', admin.token)
})
}
})
})
// Login with a specific token
Cypress.Commands.add('loginWithToken', (token) => {
cy.window().then(win => {
win.localStorage.setItem('oil_auth_token', token)
})
})
// Verify toast message appears
Cypress.Commands.add('expectToast', (text) => {
cy.get('.toast').should('contain', text)
})
// Navigate via nav tabs
Cypress.Commands.add('goToSection', (label) => {
cy.get('.nav-tab').contains(label).click()
})

File diff suppressed because one or more lines are too long

4238
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"cy:open": "cypress open",
"cy:run": "cypress run",
"test:e2e": "cypress run"
},
"dependencies": {
"exceljs": "^4.4.0",
"html2canvas": "^1.4.1",
"pinia": "^2.3.1",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"cypress": "^15.13.0",
"vite": "^8.0.4"
}
}

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 111 B

After

Width:  |  Height:  |  Size: 111 B

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,75 @@
1
00:00:00,500 --> 00:00:04,500
欢迎使用 doTERRA 精油配方计算器,这是一个功能完整的精油配方管理工具
2
00:00:05,000 --> 00:00:08,500
首页展示了所有公共配方,以卡片形式排列
3
00:00:09,000 --> 00:00:11,500
可以上下滚动浏览更多配方
4
00:00:12,000 --> 00:00:15,500
在搜索框输入关键词,可以快速筛选配方
5
00:00:16,000 --> 00:00:19,500
输入"薰衣草",立即过滤出包含薰衣草的配方
6
00:00:20,000 --> 00:00:24,000
点击任意配方卡片,查看配方详情
7
00:00:24,500 --> 00:00:29,000
详情页显示精油成分、用量、单价和总成本
8
00:00:30,000 --> 00:00:34,000
切换到"精油价目"页面,查看所有精油价格
9
00:00:34,500 --> 00:00:37,500
支持按名称搜索精油
10
00:00:38,000 --> 00:00:41,500
可以切换"瓶价"和"滴价"两种显示模式
11
00:00:42,000 --> 00:00:46,500
"管理配方"页面用于配方的增删改查和批量操作
12
00:00:47,000 --> 00:00:51,000
支持标签筛选、批量打标签、批量导出卡片
13
00:00:52,000 --> 00:00:56,000
"个人库存"页面标记自己拥有的精油
14
00:00:56,500 --> 00:00:59,500
系统会自动推荐你能制作的配方
15
00:01:00,000 --> 00:01:05,000
"操作日志"记录所有数据变更,支持按类型和用户筛选
16
00:01:06,000 --> 00:01:10,500
内置 Bug 追踪系统,支持优先级、状态流转和评论
17
00:01:12,000 --> 00:01:17,000
"用户管理"可以创建用户、分配角色、审核翻译建议
18
00:01:18,000 --> 00:01:22,000
最后回到首页,演示到此结束
19
00:01:22,500 --> 00:01:26,000
感谢观看!这是基于 Vue 3 + Vite + Pinia 构建的现代化应用

View File

@@ -0,0 +1,67 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FRONTEND_DIR="$(dirname "$SCRIPT_DIR")"
PROJECT_DIR="$(dirname "$FRONTEND_DIR")"
OUTPUT_DIR="$FRONTEND_DIR/demo-output"
mkdir -p "$OUTPUT_DIR"
echo "=== Step 1: Generate TTS audio ==="
source "$PROJECT_DIR/.venv/bin/activate"
python3 "$SCRIPT_DIR/generate-tts.py" "$OUTPUT_DIR"
echo "=== Step 2: Record Cypress demo ==="
cd "$FRONTEND_DIR"
npx cypress run --spec "cypress/e2e/demo-walkthrough.cy.js" || true
# Find the recorded video
VIDEO=$(find cypress/videos -name "demo-walkthrough*" -type f 2>/dev/null | head -1)
if [ -z "$VIDEO" ]; then
echo "ERROR: No video found in cypress/videos/"
exit 1
fi
echo "Found video: $VIDEO"
cp "$VIDEO" "$OUTPUT_DIR/raw-screen.mp4"
echo "=== Step 3: Combine video + audio + subtitles ==="
# Get video and audio durations
VIDEO_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUTPUT_DIR/raw-screen.mp4" | cut -d. -f1)
AUDIO_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUTPUT_DIR/narration.mp3" | cut -d. -f1)
echo "Video: ${VIDEO_DUR}s, Audio: ${AUDIO_DUR}s"
# Use the longer duration, speed-adjust video if needed
if [ "$VIDEO_DUR" -gt "$AUDIO_DUR" ]; then
# Speed up video to match audio length
SPEED=$(python3 -c "print(round($VIDEO_DUR / $AUDIO_DUR, 3))")
echo "Speeding up video by ${SPEED}x"
ffmpeg -y -i "$OUTPUT_DIR/raw-screen.mp4" \
-filter:v "setpts=PTS/${SPEED}" \
-an "$OUTPUT_DIR/adjusted-screen.mp4" 2>/dev/null
elif [ "$AUDIO_DUR" -gt "$VIDEO_DUR" ]; then
# Slow down video
SPEED=$(python3 -c "print(round($VIDEO_DUR / $AUDIO_DUR, 3))")
echo "Slowing video to ${SPEED}x"
ffmpeg -y -i "$OUTPUT_DIR/raw-screen.mp4" \
-filter:v "setpts=PTS/${SPEED}" \
-an "$OUTPUT_DIR/adjusted-screen.mp4" 2>/dev/null
else
cp "$OUTPUT_DIR/raw-screen.mp4" "$OUTPUT_DIR/adjusted-screen.mp4"
fi
# Combine: video + audio + burned-in subtitles
ffmpeg -y \
-i "$OUTPUT_DIR/adjusted-screen.mp4" \
-i "$OUTPUT_DIR/narration.mp3" \
-vf "subtitles=$SCRIPT_DIR/demo-subtitles.srt:force_style='FontSize=20,FontName=Noto Sans CJK SC,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Shadow=1,MarginV=30'" \
-c:v libx264 -preset fast -crf 23 \
-c:a aac -b:a 128k \
-shortest \
"$OUTPUT_DIR/demo-final.mp4" 2>/dev/null
echo ""
echo "=== Done! ==="
echo "Output: $OUTPUT_DIR/demo-final.mp4"
ls -lh "$OUTPUT_DIR/demo-final.mp4"

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Generate TTS narration for the demo video using edge-tts.
Uses XiaoxiaoNeural with natural pacing and SSML prosody control."""
import asyncio
import sys
import os
import struct
import wave
OUTPUT_DIR = sys.argv[1] if len(sys.argv) > 1 else "demo-output"
# (start_seconds, text, [optional: pause_after_ms])
# Timeline aligned with demo-subtitles.srt and demo-walkthrough.cy.js
SEGMENTS = [
(0.5, '欢迎使用 doTERRA 精油配方计算器。这是一个功能完整的精油配方管理工具。'),
(5.0, '首页展示了所有公共配方,以卡片形式排列。'),
(9.0, '可以上下滚动,浏览更多配方。'),
(12.0, '在搜索框输入关键词,可以快速筛选配方。'),
(16.0, '输入薰衣草,立即过滤出包含薰衣草的配方。'),
(20.0, '点击任意配方卡片,查看配方详情。'),
(24.5, '详情页显示精油成分、用量、单价、和总成本。'),
(30.0, '切换到精油价目页面,查看所有精油价格。'),
(34.5, '支持按名称搜索精油。'),
(38.0, '可以切换瓶价和滴价两种显示模式。'),
(42.0, '管理配方页面,用于配方的增删改查和批量操作。'),
(47.0, '支持标签筛选、批量打标签、批量导出卡片。'),
(52.0, '个人库存页面,标记自己拥有的精油。'),
(56.5, '系统会自动推荐你能制作的配方。'),
(60.0, '操作日志记录所有数据变更,支持按类型和用户筛选。'),
(66.0, '内置Bug追踪系统支持优先级、状态流转和评论。'),
(72.0, '用户管理可以创建用户、分配角色、审核翻译建议。'),
(78.0, '最后回到首页。演示到此结束。'),
(82.5, '感谢观看这是基于Vue 3、Vite和Pinia构建的现代化应用。'),
]
VOICE = "zh-CN-XiaoxiaoNeural"
RATE = "+0%"
PITCH = "+0Hz"
try:
import edge_tts
except ImportError:
print("Installing edge-tts...")
os.system(f"{sys.executable} -m pip install edge-tts -q")
import edge_tts
async def generate_segment(text, output_path):
"""Generate a single TTS segment with natural prosody."""
communicate = edge_tts.Communicate(text, VOICE, rate=RATE, pitch=PITCH)
await communicate.save(output_path)
async def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
segment_files = []
print(f"Generating {len(SEGMENTS)} TTS segments with {VOICE}...")
for i, (start_sec, text) in enumerate(SEGMENTS):
seg_path = os.path.join(OUTPUT_DIR, f"seg_{i:02d}.mp3")
print(f" [{i+1}/{len(SEGMENTS)}] {start_sec:5.1f}s {text[:40]}...")
await generate_segment(text, seg_path)
segment_files.append((start_sec, seg_path))
# Total duration: last segment start + 8s buffer
total_duration = SEGMENTS[-1][0] + 8
print(f"\nAssembling narration track ({total_duration:.0f}s)...")
# Generate silence base
silence_path = os.path.join(OUTPUT_DIR, "silence.mp3")
os.system(
f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=mono '
f'-t {total_duration} -c:a libmp3lame -q:a 2 "{silence_path}" 2>/dev/null'
)
# Build ffmpeg complex filter to overlay each segment at its timestamp
inputs = [f'-i "{silence_path}"']
filter_parts = []
for i, (start_sec, seg_path) in enumerate(segment_files):
inputs.append(f'-i "{seg_path}"')
delay_ms = int(start_sec * 1000)
# Normalize volume for each segment
filter_parts.append(f'[{i+1}]adelay={delay_ms}|{delay_ms},volume=1.5[d{i}]')
# Mix all
mix_inputs = "[0]" + "".join(f"[d{i}]" for i in range(len(segment_files)))
n_inputs = len(segment_files) + 1
filter_parts.append(
f'{mix_inputs}amix=inputs={n_inputs}:duration=longest:dropout_transition=0,'
f'volume={n_inputs}[out]'
)
filter_str = ";".join(filter_parts)
output_path = os.path.join(OUTPUT_DIR, "narration.mp3")
cmd = (
f'ffmpeg -y {" ".join(inputs)} '
f'-filter_complex "{filter_str}" '
f'-map "[out]" -c:a libmp3lame -q:a 2 "{output_path}" 2>/dev/null'
)
ret = os.system(cmd)
if ret != 0:
print("ERROR: ffmpeg narration assembly failed")
print("Trying fallback: simple concatenation...")
# Fallback: just concatenate with silence gaps
concat_list = os.path.join(OUTPUT_DIR, "concat.txt")
with open(concat_list, "w") as f:
prev_end = 0
for start_sec, seg_path in segment_files:
gap = start_sec - prev_end
if gap > 0:
gap_path = os.path.join(OUTPUT_DIR, f"gap_{prev_end:.0f}.mp3")
os.system(f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=mono -t {gap} -c:a libmp3lame -q:a 2 "{gap_path}" 2>/dev/null')
f.write(f"file '{gap_path}'\n")
f.write(f"file '{seg_path}'\n")
# Estimate segment duration ~3s
prev_end = start_sec + 3
os.system(f'ffmpeg -y -f concat -safe 0 -i "{concat_list}" -c:a libmp3lame -q:a 2 "{output_path}" 2>/dev/null')
# Cleanup
for _, seg_path in segment_files:
try: os.remove(seg_path)
except: pass
try: os.remove(silence_path)
except: pass
print(f"\nDone! Output: {output_path}")
if os.path.exists(output_path):
size = os.path.getsize(output_path) / 1024
print(f"Size: {size:.0f} KB")
else:
print("ERROR: Output file not created")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

125
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,125 @@
<template>
<div class="app-header" style="position:relative">
<div class="header-inner" style="padding-right:80px">
<div class="header-icon">🌿</div>
<div class="header-title" style="text-align:left;flex:1">
<h1 style="display:flex;justify-content:space-between;align-items:center;gap:8px;white-space:nowrap">
<span style="flex-shrink:0">doTERRA 配方计算器
<span v-if="auth.isAdmin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top">v2.2.0</span>
</span>
<span
style="cursor:pointer;color:white;font-size:13px;font-weight:500;flex-shrink:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif;letter-spacing:0.3px;opacity:0.95"
@click="toggleUserMenu"
>
<template v-if="auth.isLoggedIn">
👤 {{ auth.user.display_name || auth.user.username }}
</template>
<template v-else>
<span style="background:rgba(255,255,255,0.2);padding:4px 12px;border-radius:12px">登录</span>
</template>
</span>
</h1>
<p style="display:flex;flex-wrap:wrap;gap:4px 8px;margin:0">
<span style="white-space:nowrap">查询配方</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">计算成本</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">自制配方</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">导出卡片</span>
<span style="opacity:0.5">·</span>
<span style="white-space:nowrap">精油知识</span>
</p>
</div>
</div>
</div>
<!-- User Menu Popup -->
<UserMenu v-if="showUserMenu" @close="showUserMenu = false" />
<!-- Nav tabs -->
<div class="nav-tabs">
<div class="nav-tab" :class="{ active: ui.currentSection === 'search' }" @click="goSection('search')">🔍 配方查询</div>
<div class="nav-tab" :class="{ active: ui.currentSection === 'manage' }" @click="requireLogin('manage')">📋 管理配方</div>
<div class="nav-tab" :class="{ active: ui.currentSection === 'inventory' }" @click="requireLogin('inventory')">📦 个人库存</div>
<div class="nav-tab" :class="{ active: ui.currentSection === 'oils' }" @click="goSection('oils')">💧 精油价目</div>
<div v-if="auth.isBusiness" class="nav-tab" :class="{ active: ui.currentSection === 'projects' }" @click="goSection('projects')">💼 商业核算</div>
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'audit' }" @click="goSection('audit')">📜 操作日志</div>
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'bugs' }" @click="goSection('bugs')">🐛 Bug</div>
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'users' }" @click="goSection('users')">👥 用户管理</div>
</div>
<!-- Main content -->
<div class="main">
<router-view />
</div>
<!-- Login Modal -->
<LoginModal v-if="ui.showLoginModal" @close="ui.closeLogin()" />
<!-- Custom Dialog -->
<CustomDialog />
<!-- Toast messages -->
<div v-for="(toast, i) in ui.toasts" :key="i" class="toast">{{ toast }}</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useOilsStore } from './stores/oils'
import { useRecipesStore } from './stores/recipes'
import { useUiStore } from './stores/ui'
import LoginModal from './components/LoginModal.vue'
import CustomDialog from './components/CustomDialog.vue'
import UserMenu from './components/UserMenu.vue'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const router = useRouter()
const showUserMenu = ref(false)
function goSection(name) {
ui.showSection(name)
router.push('/' + (name === 'search' ? '' : name))
}
function requireLogin(name) {
if (!auth.isLoggedIn) {
ui.openLogin()
return
}
goSection(name)
}
function toggleUserMenu() {
if (!auth.isLoggedIn) {
ui.openLogin()
return
}
showUserMenu.value = !showUserMenu.value
}
onMounted(async () => {
await auth.initToken()
await Promise.all([
oils.loadOils(),
recipeStore.loadRecipes(),
recipeStore.loadTags(),
])
if (auth.isLoggedIn) {
await recipeStore.loadFavorites()
}
// Periodic refresh
setInterval(async () => {
if (document.visibilityState !== 'visible') return
try {
await auth.loadMe()
} catch {}
}, 15000)
})
</script>

View File

@@ -0,0 +1,501 @@
:root {
--cream: #faf6f0;
--warm-white: #fffdf9;
--sage: #7a9e7e;
--sage-dark: #5a7d5e;
--sage-light: #c8ddc9;
--sage-mist: #eef4ee;
--gold: #c9a84c;
--gold-light: #f0e4c0;
--brown: #6b4f3a;
--brown-light: #c4a882;
--text-dark: #2c2416;
--text-mid: #5a4a35;
--text-light: #9a8570;
--border: #e0d4c0;
--shadow: 0 4px 20px rgba(90,60,30,0.08);
--shadow-hover: 0 8px 32px rgba(90,60,30,0.15);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Noto Sans SC', sans-serif;
background: var(--cream);
color: var(--text-dark);
min-height: 100vh;
}
/* Header */
.app-header {
background: linear-gradient(135deg, #3d6b41 0%, #5a7d5e 50%, #7a9e7e 100%);
padding: 28px 32px 24px;
position: relative;
overflow: hidden;
}
.app-header::before {
content: '';
position: absolute; inset: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
.header-inner { position: relative; z-index: 1; display: flex; align-items: center; gap: 16px; }
.header-icon { font-size: 40px; }
.header-title { color: white; }
.header-title h1 { font-family: 'Noto Serif SC', serif; font-size: 24px; font-weight: 600; letter-spacing: 2px; }
.header-title p { font-size: 13px; opacity: 0.8; margin-top: 4px; letter-spacing: 1px; }
/* Nav tabs */
.nav-tabs {
display: flex;
background: white;
border-bottom: 1px solid var(--border);
padding: 0 24px;
gap: 0;
overflow-x: auto;
position: sticky;
top: 0;
z-index: 50;
}
.nav-tab {
padding: 14px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-light);
cursor: pointer;
border-bottom: 3px solid transparent;
white-space: nowrap;
transition: all 0.2s;
}
.nav-tab:hover { color: var(--sage-dark); }
.nav-tab.active { color: var(--sage-dark); border-bottom-color: var(--sage); }
/* Main content */
.main { padding: 24px; max-width: 960px; margin: 0 auto; }
/* Section */
.section { max-width: 800px; margin-left: auto; margin-right: auto; }
/* Search box */
.search-box {
background: white;
border-radius: 16px;
padding: 20px 24px;
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.search-label { font-size: 13px; color: var(--text-light); margin-bottom: 10px; letter-spacing: 0.5px; }
.search-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.search-input {
flex: 1; min-width: 200px;
padding: 11px 16px;
border: 1.5px solid var(--border);
border-radius: 10px;
font-size: 15px;
font-family: inherit;
color: var(--text-dark);
background: var(--cream);
transition: border-color 0.2s;
outline: none;
}
.search-input:focus { border-color: var(--sage); background: white; }
.btn {
padding: 11px 22px;
border-radius: 10px;
border: none;
font-size: 14px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-primary { background: var(--sage); color: white; }
.btn-primary:hover { background: var(--sage-dark); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(90,125,94,0.3); }
.btn-gold { background: var(--gold); color: white; }
.btn-gold:hover { background: #b8973e; transform: translateY(-1px); }
.btn-outline { background: transparent; color: var(--sage-dark); border: 1.5px solid var(--sage); }
.btn-outline:hover { background: var(--sage-mist); }
.btn-danger { background: transparent; color: #c0392b; border: 1.5px solid #e8b4b0; }
.btn-danger:hover { background: #fdf0ee; }
.btn-sm { padding: 7px 14px; font-size: 13px; }
/* Recipe grid */
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.recipe-card {
background: white;
border-radius: 14px;
padding: 18px;
cursor: pointer;
box-shadow: var(--shadow);
border: 2px solid transparent;
transition: all 0.2s;
position: relative;
}
.recipe-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-hover); border-color: var(--sage-light); }
.recipe-card.selected { border-color: var(--sage); background: var(--sage-mist); }
.recipe-card-name {
font-family: 'Noto Serif SC', serif;
font-size: 16px;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 8px;
}
.recipe-card-oils { font-size: 12px; color: var(--text-light); line-height: 1.7; }
.recipe-card-price {
margin-top: 12px;
font-size: 13px;
color: var(--sage-dark);
font-weight: 600;
display: flex; align-items: center; gap: 6px;
}
/* Detail panel */
.detail-panel {
background: white;
border-radius: 16px;
padding: 28px;
box-shadow: var(--shadow);
margin-bottom: 24px;
}
.detail-header {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: 24px; flex-wrap: wrap; gap: 12px;
}
.detail-title {
font-family: 'Noto Serif SC', serif;
font-size: 22px; font-weight: 700; color: var(--text-dark);
}
.detail-note {
font-size: 13px; color: var(--text-light);
background: var(--gold-light); border-radius: 8px;
padding: 6px 12px; margin-top: 6px;
display: inline-block;
}
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
/* Ingredients table */
.ingredients-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.ingredients-table th {
text-align: center; padding: 10px 14px;
font-size: 12px; font-weight: 600;
color: var(--text-light); letter-spacing: 0.5px;
border-bottom: 2px solid var(--border);
text-transform: uppercase;
}
.ingredients-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
font-size: 14px; vertical-align: middle;
text-align: center;
}
.ingredients-table tr:last-child td { border-bottom: none; }
.ingredients-table tr:hover td { background: var(--sage-mist); }
.drops-input {
width: 70px; padding: 6px 10px;
border: 1.5px solid var(--border); border-radius: 8px;
font-size: 14px; font-family: inherit; text-align: center;
outline: none; transition: border-color 0.2s;
}
.drops-input:focus { border-color: var(--sage); }
.oil-select {
padding: 6px 10px;
border: 1.5px solid var(--border); border-radius: 8px;
font-size: 13px; font-family: inherit;
background: white; outline: none;
max-width: 160px;
}
.oil-select:focus { border-color: var(--sage); }
.remove-btn {
background: none; border: none; cursor: pointer;
color: #c0392b; font-size: 18px; padding: 4px 8px;
border-radius: 6px; transition: background 0.2s;
}
.remove-btn:hover { background: #fdf0ee; }
.total-row {
background: var(--sage-mist);
border-radius: 12px; padding: 16px 20px;
display: flex; justify-content: space-between; align-items: center;
margin-top: 16px;
}
.total-label { font-size: 14px; color: var(--text-mid); font-weight: 500; }
.total-price { font-size: 22px; font-weight: 700; color: var(--sage-dark); }
/* Add ingredient */
.add-ingredient-row {
display: flex; gap: 10px; align-items: center;
margin-top: 12px; flex-wrap: wrap;
}
.add-ingredient-row select, .add-ingredient-row input {
padding: 8px 12px; border: 1.5px solid var(--border);
border-radius: 8px; font-size: 13px; font-family: inherit;
outline: none;
}
.add-ingredient-row select:focus, .add-ingredient-row input:focus { border-color: var(--sage); }
/* Card preview for export */
.card-preview-wrapper { margin-top: 20px; }
.card-brand {
font-size: 11px; letter-spacing: 3px; color: var(--sage);
margin-bottom: 8px;
}
.card-title {
font-size: 26px; font-weight: 700; color: var(--text-dark);
margin-bottom: 6px; line-height: 1.3;
}
.card-divider {
width: 48px; height: 2px;
background: linear-gradient(90deg, var(--sage), var(--gold));
border-radius: 2px; margin: 14px 0;
}
.card-note { font-size: 12px; color: var(--brown-light); margin-bottom: 18px; }
.card-ingredients { list-style: none; margin-bottom: 20px; }
.card-ingredients li {
display: flex; align-items: center;
padding: 9px 0; border-bottom: 1px solid rgba(180,150,100,0.15);
font-size: 14px;
}
.card-ingredients li:last-child { border-bottom: none; }
.card-oil-name { flex: 1; color: var(--text-dark); font-weight: 500; }
.card-oil-drops { width: 60px; text-align: right; color: var(--sage-dark); font-size: 13px; }
.card-oil-cost { width: 70px; text-align: right; color: var(--text-light); font-size: 12px; }
.card-total {
background: linear-gradient(135deg, var(--sage), #5a7d5e);
border-radius: 12px; padding: 14px 20px;
display: flex; justify-content: space-between; align-items: center;
margin-top: 8px;
}
.card-total-label { color: rgba(255,255,255,0.85); font-size: 13px; letter-spacing: 1px; }
.card-total-price { color: white; font-size: 20px; font-weight: 700; }
.card-footer {
margin-top: 16px; text-align: center;
font-size: 11px; color: var(--text-light); letter-spacing: 1px;
}
/* Manage section */
.manage-list { display: flex; flex-direction: column; gap: 12px; }
.manage-item {
background: white; border-radius: 14px; padding: 18px 22px;
box-shadow: var(--shadow); display: flex;
justify-content: space-between; align-items: center;
gap: 12px; flex-wrap: wrap;
}
.manage-item-left { flex: 1; }
.manage-item-name { font-weight: 600; font-size: 16px; color: var(--text-dark); }
.manage-item-oils { font-size: 13px; color: var(--text-light); margin-top: 4px; }
.manage-item-actions { display: flex; gap: 8px; flex-shrink: 0; flex-wrap: wrap; }
/* Add recipe form */
.form-card {
background: white; border-radius: 16px;
padding: 28px; box-shadow: var(--shadow); margin-bottom: 24px;
}
.form-title { font-family: 'Noto Serif SC', serif; font-size: 18px; font-weight: 600; margin-bottom: 20px; color: var(--text-dark); }
.form-group { margin-bottom: 16px; }
.form-label { font-size: 13px; color: var(--text-mid); margin-bottom: 6px; display: block; font-weight: 500; }
.form-control {
width: 100%; padding: 10px 14px;
border: 1.5px solid var(--border); border-radius: 10px;
font-size: 14px; font-family: inherit; outline: none;
transition: border-color 0.2s; background: white;
}
.form-control:focus { border-color: var(--sage); }
.new-ing-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.new-ing-row { display: flex; gap: 8px; align-items: center; }
.new-ing-row select { flex: 1; }
.new-ing-row input { width: 80px; }
/* Oils section */
.oils-search { margin-bottom: 16px; }
.oils-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.oil-chip {
background: white; border-radius: 10px; padding: 12px 16px;
box-shadow: 0 2px 8px rgba(90,60,30,0.06);
display: flex; justify-content: space-between; align-items: center;
gap: 8px;
}
.oil-chip-name { font-size: 14px; color: var(--text-dark); font-weight: 500; }
.oil-chip-price { font-size: 13px; color: var(--sage-dark); font-weight: 600; }
.oil-chip-actions { display: flex; gap: 4px; }
.oil-chip-btn {
background: none; border: none; cursor: pointer;
font-size: 13px; padding: 3px 6px; border-radius: 6px;
transition: background 0.2s; color: var(--text-light);
}
.oil-chip-btn:hover { background: var(--sage-mist); color: var(--sage-dark); }
.oil-chip-btn.del:hover { background: #fdf0ee; color: #c0392b; }
.oil-edit-input {
width: 90px; padding: 4px 8px; border: 1.5px solid var(--sage);
border-radius: 6px; font-size: 13px; font-family: inherit;
text-align: center; outline: none;
}
.add-oil-form {
background: white; border-radius: 14px; padding: 16px 20px;
box-shadow: var(--shadow); margin-bottom: 16px;
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
}
.add-oil-form input {
padding: 9px 14px; border: 1.5px solid var(--border);
border-radius: 8px; font-size: 14px; font-family: inherit; outline: none;
}
.add-oil-form input:focus { border-color: var(--sage); }
/* Empty state */
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-light); }
.empty-state-icon { font-size: 48px; margin-bottom: 12px; }
.empty-state-text { font-size: 15px; }
/* Tag */
.tag {
display: inline-block; padding: 3px 10px;
border-radius: 20px; font-size: 12px;
background: var(--sage-mist); color: var(--sage-dark);
margin: 2px;
}
.tag-btn {
display: inline-flex; align-items: center; gap: 3px;
padding: 4px 10px; border-radius: 16px; font-size: 12px;
background: var(--sage-mist); color: var(--sage-dark);
border: 1.5px solid transparent; cursor: pointer;
transition: all 0.2s;
}
.tag-btn:hover { border-color: var(--sage); }
.tag-btn.active { background: var(--sage); color: white; border-color: var(--sage); }
.tag-btn .tag-del {
font-size: 14px; margin-left: 2px; opacity: 0.5;
cursor: pointer; border: none; background: none;
color: inherit; padding: 0 2px;
}
.tag-btn .tag-del:hover { opacity: 1; }
/* Tag picker overlay */
.tag-picker {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3); z-index: 999;
display: flex; align-items: center; justify-content: center;
}
.tag-picker-card {
background: white; border-radius: 16px; padding: 24px;
box-shadow: 0 8px 40px rgba(0,0,0,0.2); max-width: 400px; width: 90%;
}
.tag-picker-title { font-family: 'Noto Serif SC', serif; font-size: 16px; font-weight: 600; margin-bottom: 14px; }
.tag-picker-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.tag-pick {
padding: 6px 14px; border-radius: 20px; font-size: 13px;
border: 1.5px solid var(--border); background: white;
color: var(--text-mid); cursor: pointer; transition: all 0.15s;
}
.tag-pick:hover { border-color: var(--sage); }
.tag-pick.selected { background: var(--sage); color: white; border-color: var(--sage); }
/* Hint */
.hint { font-size: 12px; color: var(--text-light); margin-top: 6px; }
.section-title {
font-family: 'Noto Serif SC', serif;
font-size: 18px; font-weight: 600; color: var(--text-dark);
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
}
/* Category carousel */
.cat-wrap { position: relative; margin: 0 -24px 20px; overflow: hidden; }
.cat-track { display: flex; transition: transform 0.4s ease; will-change: transform; }
.cat-card {
flex: 0 0 100%; min-height: 200px; position: relative; overflow: hidden; cursor: pointer;
background-size: cover; background-position: center;
}
.cat-card::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(0,0,0,0.45), rgba(0,0,0,0.25));
}
.cat-inner {
position: relative; z-index: 1; height: 100%; display: flex; flex-direction: column;
justify-content: center; align-items: center; padding: 36px 24px; color: white; text-align: center;
}
.cat-icon { font-size: 48px; margin-bottom: 10px; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3)); }
.cat-name { font-family: 'Noto Serif SC', serif; font-size: 24px; font-weight: 700; letter-spacing: 3px; text-shadow: 0 2px 8px rgba(0,0,0,0.5); }
.cat-sub { font-size: 13px; margin-top: 6px; opacity: 0.9; letter-spacing: 1px; }
.cat-arrow {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
width: 36px; height: 36px; border-radius: 50%; background: rgba(255,255,255,0.25);
border: none; color: white; font-size: 18px; cursor: pointer; backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; transition: background 0.2s;
}
.cat-arrow:hover { background: rgba(255,255,255,0.45); }
.cat-arrow.left { left: 12px; }
.cat-arrow.right { right: 12px; }
.cat-dots { display: flex; justify-content: center; gap: 8px; margin-bottom: 14px; }
.cat-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); cursor: pointer; transition: all 0.25s; }
.cat-dot.active { background: var(--sage); width: 22px; border-radius: 4px; }
/* Toast */
.toast {
position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.8); color: white; padding: 10px 24px;
border-radius: 20px; font-size: 14px; z-index: 999;
pointer-events: none; transition: opacity 0.3s;
}
/* Custom dialog overlay */
.dialog-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.dialog-box {
background: white; border-radius: 16px; padding: 28px 24px 20px;
max-width: 340px; width: 100%;
box-shadow: 0 12px 40px rgba(0,0,0,0.2); font-family: inherit;
}
.dialog-msg {
font-size: 14px; color: #333; line-height: 1.6;
white-space: pre-line; word-break: break-word;
margin-bottom: 20px; text-align: center;
}
.dialog-btn-row { display: flex; gap: 10px; justify-content: center; }
.dialog-btn-primary {
flex: 1; max-width: 140px; padding: 10px 0; border: none; border-radius: 10px;
font-size: 14px; font-weight: 600; cursor: pointer;
background: linear-gradient(135deg, #7a9e7e, #5a7d5e); color: white;
}
.dialog-btn-outline {
flex: 1; max-width: 140px; padding: 10px 0;
border: 1.5px solid #d4cfc7; border-radius: 10px;
font-size: 14px; cursor: pointer; background: white; color: #666;
}
/* Responsive */
@media (max-width: 600px) {
.main { padding: 8px; }
.section { max-width: 100%; }
.detail-panel { padding: 12px; }
.recipe-grid { grid-template-columns: 1fr; }
.ingredients-table { font-size: 12px; }
.ingredients-table td, .ingredients-table th { padding: 6px 4px; }
.oil-select { max-width: 100px; font-size: 11px; }
.drops-input { width: 50px; font-size: 12px; }
.search-input { padding: 8px 10px; font-size: 14px; }
.search-box { padding: 12px; }
.search-label { font-size: 12px; margin-bottom: 6px; }
.form-card { padding: 16px; }
.section-title { font-size: 16px; }
.manage-item { padding: 10px 12px; }
.nav-tab { padding: 10px 12px; font-size: 13px; }
.oil-chip { padding: 10px 12px; }
.oils-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.app-header { padding: 20px 16px 18px; }
.header-title h1 { font-size: 20px; }
}

View File

@@ -0,0 +1,120 @@
<template>
<div v-if="dialogState.visible" class="dialog-overlay">
<div class="dialog-box">
<div class="dialog-msg">{{ dialogState.message }}</div>
<input
v-if="dialogState.type === 'prompt'"
v-model="inputValue"
type="text"
style="width:100%;padding:10px 14px;border:1.5px solid #d4cfc7;border-radius:10px;font-size:14px;margin-bottom:16px;outline:none;font-family:inherit;box-sizing:border-box"
@keydown.enter="submitPrompt"
ref="promptInput"
/>
<div class="dialog-btn-row">
<button v-if="dialogState.type !== 'alert'" class="dialog-btn-outline" @click="cancel">取消</button>
<button class="dialog-btn-primary" @click="ok">确定</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { dialogState, closeDialog } from '../composables/useDialog'
const inputValue = ref('')
const promptInput = ref(null)
watch(() => dialogState.visible, (v) => {
if (v && dialogState.type === 'prompt') {
inputValue.value = dialogState.defaultValue || ''
nextTick(() => {
promptInput.value?.focus()
promptInput.value?.select()
})
}
})
function ok() {
if (dialogState.type === 'alert') closeDialog()
else if (dialogState.type === 'confirm') closeDialog(true)
else closeDialog(inputValue.value)
}
function cancel() {
if (dialogState.type === 'confirm') closeDialog(false)
else closeDialog(null)
}
function submitPrompt() {
closeDialog(inputValue.value)
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-box {
background: #fff;
border-radius: 16px;
padding: 28px 24px 20px;
min-width: 280px;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
text-align: center;
}
.dialog-msg {
font-size: 15px;
color: #3e3a44;
margin-bottom: 18px;
line-height: 1.6;
white-space: pre-wrap;
}
.dialog-btn-row {
display: flex;
gap: 10px;
justify-content: center;
}
.dialog-btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 28px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.dialog-btn-primary:hover {
opacity: 0.9;
}
.dialog-btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 28px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.dialog-btn-outline:hover {
background: #f8f7f5;
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="login-overlay" @click.self="$emit('close')">
<div class="login-card">
<div class="login-header">
<span
class="login-tab"
:class="{ active: mode === 'login' }"
@click="mode = 'login'"
>登录</span>
<span
class="login-tab"
:class="{ active: mode === 'register' }"
@click="mode = 'register'"
>注册</span>
</div>
<div class="login-body">
<input
v-model="username"
type="text"
placeholder="用户名"
class="login-input"
@keydown.enter="submit"
/>
<input
v-model="password"
type="password"
placeholder="密码"
class="login-input"
@keydown.enter="submit"
/>
<input
v-if="mode === 'register'"
v-model="displayName"
type="text"
placeholder="显示名称(可选)"
class="login-input"
@keydown.enter="submit"
/>
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
<button class="login-submit" :disabled="loading" @click="submit">
{{ loading ? '请稍候...' : (mode === 'login' ? '登录' : '注册') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
const emit = defineEmits(['close'])
const auth = useAuthStore()
const ui = useUiStore()
const mode = ref('login')
const username = ref('')
const password = ref('')
const displayName = ref('')
const errorMsg = ref('')
const loading = ref(false)
async function submit() {
errorMsg.value = ''
if (!username.value.trim()) {
errorMsg.value = '请输入用户名'
return
}
if (!password.value) {
errorMsg.value = '请输入密码'
return
}
loading.value = true
try {
if (mode.value === 'login') {
await auth.login(username.value.trim(), password.value)
ui.showToast('登录成功')
} else {
await auth.register(
username.value.trim(),
password.value,
displayName.value.trim() || username.value.trim()
)
ui.showToast('注册成功')
}
emit('close')
// Reload page data after auth change
window.location.reload()
} catch (e) {
errorMsg.value = e?.message || (mode.value === 'login' ? '登录失败,请检查用户名和密码' : '注册失败,请重试')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 5000;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: #fff;
border-radius: 18px;
width: 340px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.login-header {
display: flex;
border-bottom: 1px solid #eee;
}
.login-tab {
flex: 1;
text-align: center;
padding: 14px 0;
font-size: 15px;
font-weight: 500;
color: #999;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
border-bottom: 2px solid transparent;
}
.login-tab.active {
color: #4a9d7e;
border-bottom-color: #4a9d7e;
}
.login-body {
padding: 24px 24px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.login-input {
width: 100%;
padding: 11px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
transition: border-color 0.2s;
}
.login-input:focus {
border-color: #4a9d7e;
}
.login-error {
color: #d9534f;
font-size: 13px;
text-align: center;
}
.login-submit {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 11px 0;
font-size: 15px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: opacity 0.2s;
}
.login-submit:hover {
opacity: 0.9;
}
.login-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div class="recipe-card" @click="$emit('click', index)">
<div class="card-name">{{ recipe.name }}</div>
<div v-if="recipe.tags && recipe.tags.length" class="card-tags">
<span v-for="tag in recipe.tags" :key="tag" class="card-tag">{{ tag }}</span>
</div>
<div class="card-oils">
<span v-for="(ing, i) in recipe.ingredients" :key="i" class="card-oil">
{{ ing.oil }}
</span>
</div>
<div class="card-bottom">
<span class="card-price">
{{ priceInfo.cost }}
<span v-if="priceInfo.hasRetail" class="card-retail">零售 {{ priceInfo.retail }}</span>
</span>
<button
class="card-star"
:class="{ favorited: isFav }"
@click.stop="$emit('toggle-fav', recipe._id)"
:title="isFav ? '取消收藏' : '收藏'"
>
{{ isFav ? '★' : '☆' }}
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
const props = defineProps({
recipe: { type: Object, required: true },
index: { type: Number, required: true },
})
defineEmits(['click', 'toggle-fav'])
const oilsStore = useOilsStore()
const recipesStore = useRecipesStore()
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(props.recipe.ingredients))
const isFav = computed(() => recipesStore.isFavorite(props.recipe))
</script>
<style scoped>
.recipe-card {
background: #fff;
border-radius: 14px;
padding: 18px 16px 14px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: box-shadow 0.2s, transform 0.15s;
display: flex;
flex-direction: column;
gap: 8px;
}
.recipe-card:hover {
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card-name {
font-family: 'Noto Serif SC', serif;
font-size: 16px;
font-weight: 600;
color: #3e3a44;
line-height: 1.3;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.card-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
background: #f0ece4;
color: #8a7e6b;
}
.card-oils {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.card-oil {
font-size: 12px;
color: #6b6375;
background: #f8f7f5;
padding: 2px 7px;
border-radius: 6px;
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 6px;
}
.card-price {
font-size: 14px;
font-weight: 600;
color: #4a9d7e;
}
.card-retail {
font-size: 11px;
font-weight: 400;
color: #999;
margin-left: 6px;
}
.card-star {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #ccc;
padding: 2px 4px;
line-height: 1;
transition: color 0.2s;
}
.card-star.favorited {
color: #f5a623;
}
.card-star:hover {
color: #f5a623;
}
</style>

View File

@@ -0,0 +1,636 @@
<template>
<div class="detail-overlay" @click.self="$emit('close')">
<div class="detail-panel">
<!-- Mode toggle -->
<div class="detail-mode-tabs">
<button
class="mode-tab"
:class="{ active: viewMode === 'card' }"
@click="viewMode = 'card'"
>卡片预览</button>
<button
class="mode-tab"
:class="{ active: viewMode === 'editor' }"
@click="viewMode = 'editor'"
>编辑</button>
<button class="detail-close-btn" @click="$emit('close')"></button>
</div>
<!-- Card View -->
<div v-if="viewMode === 'card'" class="detail-card-view">
<div ref="cardRef" class="export-card">
<div class="export-card-name">{{ recipe.name }}</div>
<div v-if="recipe.tags && recipe.tags.length" class="export-card-tags">
<span v-for="tag in recipe.tags" :key="tag" class="export-card-tag">{{ tag }}</span>
</div>
<table class="export-card-table">
<thead>
<tr>
<th>精油</th>
<th>滴数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
<tr v-for="(ing, i) in recipe.ingredients" :key="i">
<td>{{ ing.oil }}</td>
<td>{{ ing.drops }}</td>
<td>{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" style="text-align:right;font-weight:600">总计</td>
<td style="font-weight:600">{{ priceInfo.cost }}</td>
</tr>
<tr v-if="priceInfo.hasRetail">
<td colspan="2" style="text-align:right;color:#999">零售价</td>
<td style="color:#999">{{ priceInfo.retail }}</td>
</tr>
</tfoot>
</table>
<div v-if="recipe.note" class="export-card-note">{{ recipe.note }}</div>
</div>
<div class="detail-card-actions">
<button class="action-btn" @click="exportImage">📤 导出图片</button>
<button class="action-btn" @click="$emit('close')">关闭</button>
</div>
</div>
<!-- Editor View -->
<div v-if="viewMode === 'editor'" class="detail-editor-view">
<div class="editor-section">
<label class="editor-label">配方名称</label>
<input v-model="editName" type="text" class="editor-input" />
</div>
<div class="editor-section">
<label class="editor-label">备注</label>
<textarea v-model="editNote" class="editor-textarea" rows="2"></textarea>
</div>
<div class="editor-section">
<label class="editor-label">标签</label>
<div class="editor-tags">
<span v-for="tag in editTags" :key="tag" class="editor-tag">
{{ tag }}
<span class="tag-remove" @click="removeTag(tag)">×</span>
</span>
<button class="tag-add-btn" @click="showTagPicker = true">+ 标签</button>
</div>
</div>
<div class="editor-section">
<label class="editor-label">成分</label>
<table class="editor-table">
<thead>
<tr>
<th>精油</th>
<th>滴数</th>
<th>成本</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(ing, i) in editIngredients" :key="i">
<td>
<select v-model="ing.oil" class="editor-select">
<option value="">选择精油</option>
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
</td>
<td>
<input v-model.number="ing.drops" type="number" min="1" class="editor-drops" />
</td>
<td class="ing-cost">
{{ ing.oil ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * (ing.drops || 0)) : '-' }}
</td>
<td>
<button class="remove-row-btn" @click="removeIngredient(i)"></button>
</td>
</tr>
</tbody>
</table>
<button class="add-row-btn" @click="addIngredient">+ 添加精油</button>
</div>
<div class="editor-section">
<label class="editor-label">容量</label>
<div class="volume-controls">
<button
v-for="(drops, ml) in volumeOptions"
:key="ml"
class="volume-btn"
:class="{ active: selectedVolume === ml }"
@click="selectedVolume = ml"
>{{ ml }}ml</button>
</div>
</div>
<div class="editor-total">
总计: {{ editPriceInfo.cost }}
<span v-if="editPriceInfo.hasRetail" style="color:#999;font-size:13px;margin-left:8px">
零售 {{ editPriceInfo.retail }}
</span>
</div>
<div class="editor-actions">
<button class="action-btn" @click="$emit('close')">取消</button>
<button class="action-btn action-btn-primary" @click="saveRecipe">保存</button>
</div>
</div>
<!-- Tag Picker -->
<TagPicker
v-if="showTagPicker"
:name="editName"
:current-tags="editTags"
:all-tags="recipesStore.allTags"
@save="onTagsSaved"
@close="showTagPicker = false"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import html2canvas from 'html2canvas'
import { useOilsStore, VOLUME_DROPS } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import TagPicker from './TagPicker.vue'
const props = defineProps({
recipeIndex: { type: Number, required: true },
})
const emit = defineEmits(['close'])
const oilsStore = useOilsStore()
const recipesStore = useRecipesStore()
const ui = useUiStore()
const viewMode = ref('card')
const cardRef = ref(null)
const showTagPicker = ref(false)
const selectedVolume = ref('5')
const volumeOptions = VOLUME_DROPS
// Source recipe
const recipe = computed(() => recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' })
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(recipe.value.ingredients))
// Editable copies
const editName = ref('')
const editNote = ref('')
const editTags = ref([])
const editIngredients = ref([])
const editPriceInfo = computed(() => oilsStore.fmtCostWithRetail(editIngredients.value.filter(i => i.oil)))
onMounted(() => {
const r = recipe.value
editName.value = r.name
editNote.value = r.note || ''
editTags.value = [...(r.tags || [])]
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
})
function addIngredient() {
editIngredients.value.push({ oil: '', drops: 1 })
}
function removeIngredient(index) {
editIngredients.value.splice(index, 1)
}
function removeTag(tag) {
editTags.value = editTags.value.filter(t => t !== tag)
}
function onTagsSaved(tags) {
editTags.value = tags
showTagPicker.value = false
}
async function saveRecipe() {
const ingredients = editIngredients.value.filter(i => i.oil && i.drops > 0)
if (!editName.value.trim()) {
ui.showToast('请输入配方名称')
return
}
if (ingredients.length === 0) {
ui.showToast('请至少添加一种精油')
return
}
try {
const payload = {
...recipe.value,
name: editName.value.trim(),
note: editNote.value.trim(),
tags: editTags.value,
ingredients,
}
await recipesStore.saveRecipe(payload)
ui.showToast('保存成功')
emit('close')
} catch (e) {
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
}
}
async function exportImage() {
if (!cardRef.value) return
try {
const canvas = await html2canvas(cardRef.value, {
backgroundColor: '#fff',
scale: 2,
})
const link = document.createElement('a')
link.download = `${recipe.value.name || '配方'}.png`
link.href = canvas.toDataURL('image/png')
link.click()
ui.showToast('已导出图片')
} catch (e) {
ui.showToast('导出失败')
}
}
</script>
<style scoped>
.detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 5500;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.detail-panel {
background: #fff;
border-radius: 18px;
width: 520px;
max-width: 100%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
position: relative;
}
.detail-mode-tabs {
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
padding: 0 16px;
gap: 4px;
}
.mode-tab {
padding: 14px 16px;
font-size: 14px;
font-weight: 500;
color: #999;
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
font-family: inherit;
transition: color 0.2s;
}
.mode-tab.active {
color: #4a9d7e;
border-bottom-color: #4a9d7e;
}
.detail-close-btn {
margin-left: auto;
background: none;
border: none;
font-size: 18px;
color: #999;
cursor: pointer;
padding: 8px;
}
.detail-close-btn:hover {
color: #333;
}
/* Card View */
.detail-card-view {
padding: 20px;
}
.export-card {
background: #fefdfb;
border: 1px solid #eee;
border-radius: 12px;
padding: 24px 20px;
}
.export-card-name {
font-family: 'Noto Serif SC', serif;
font-size: 20px;
font-weight: 700;
color: #3e3a44;
margin-bottom: 10px;
}
.export-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.export-card-tag {
font-size: 11px;
padding: 2px 10px;
border-radius: 8px;
background: #f0ece4;
color: #8a7e6b;
}
.export-card-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
margin-bottom: 12px;
}
.export-card-table th {
text-align: left;
font-weight: 500;
color: #999;
border-bottom: 1px solid #eee;
padding: 8px 6px;
font-size: 12px;
}
.export-card-table td {
padding: 7px 6px;
color: #3e3a44;
border-bottom: 1px solid #f5f5f3;
}
.export-card-table tfoot td {
border-bottom: none;
padding-top: 10px;
}
.export-card-note {
font-size: 13px;
color: #999;
margin-top: 8px;
line-height: 1.5;
white-space: pre-wrap;
}
.detail-card-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
}
/* Editor View */
.detail-editor-view {
padding: 20px;
}
.editor-section {
margin-bottom: 18px;
}
.editor-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #999;
margin-bottom: 6px;
}
.editor-input {
width: 100%;
padding: 10px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.editor-input:focus {
border-color: #4a9d7e;
}
.editor-textarea {
width: 100%;
padding: 10px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
resize: vertical;
}
.editor-textarea:focus {
border-color: #4a9d7e;
}
.editor-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.editor-tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 10px;
background: #4a9d7e;
color: #fff;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tag-remove {
cursor: pointer;
font-size: 14px;
opacity: 0.7;
}
.tag-remove:hover {
opacity: 1;
}
.tag-add-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 10px;
border: 1.5px dashed #ccc;
background: none;
color: #999;
cursor: pointer;
font-family: inherit;
}
.tag-add-btn:hover {
border-color: #4a9d7e;
color: #4a9d7e;
}
.editor-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.editor-table th {
text-align: left;
font-weight: 500;
color: #999;
padding: 6px 4px;
font-size: 12px;
border-bottom: 1px solid #eee;
}
.editor-table td {
padding: 5px 4px;
}
.editor-select {
width: 100%;
padding: 7px 8px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.editor-drops {
width: 60px;
padding: 7px 8px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 13px;
text-align: center;
font-family: inherit;
}
.ing-cost {
font-size: 12px;
color: #4a9d7e;
white-space: nowrap;
}
.remove-row-btn {
background: none;
border: none;
color: #ccc;
font-size: 14px;
cursor: pointer;
padding: 4px;
}
.remove-row-btn:hover {
color: #d9534f;
}
.add-row-btn {
margin-top: 8px;
background: none;
border: 1.5px dashed #ccc;
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
color: #999;
cursor: pointer;
font-family: inherit;
width: 100%;
}
.add-row-btn:hover {
border-color: #4a9d7e;
color: #4a9d7e;
}
.volume-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.volume-btn {
padding: 6px 16px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
background: #fff;
font-size: 13px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.volume-btn.active {
background: #4a9d7e;
color: #fff;
border-color: #4a9d7e;
}
.editor-total {
font-size: 16px;
font-weight: 600;
color: #4a9d7e;
padding: 12px 0;
border-top: 1px solid #eee;
margin-bottom: 4px;
}
.editor-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Shared action buttons */
.action-btn {
padding: 9px 22px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
background: #fff;
font-size: 14px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
}
.action-btn:hover {
background: #f8f7f5;
}
.action-btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border-color: transparent;
}
.action-btn-primary:hover {
opacity: 0.9;
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="tagpicker-overlay" @click.self="$emit('close')">
<div class="tagpicker-card">
<div class="tagpicker-title">{{ name }}选择标签</div>
<div class="tagpicker-pills">
<span
v-for="tag in allTags"
:key="tag"
class="tagpicker-pill"
:class="{ selected: selectedTags.has(tag) }"
@click="toggleTag(tag)"
>
{{ tag }}
</span>
</div>
<div class="tagpicker-new">
<input
v-model="newTag"
type="text"
placeholder="添加新标签..."
class="tagpicker-input"
@keydown.enter="addNewTag"
/>
<button class="tagpicker-add-btn" @click="addNewTag" :disabled="!newTag.trim()">+</button>
</div>
<div class="tagpicker-actions">
<button class="tagpicker-btn-cancel" @click="$emit('close')">取消</button>
<button class="tagpicker-btn-confirm" @click="save">确定</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const props = defineProps({
name: { type: String, default: '' },
currentTags: { type: Array, default: () => [] },
allTags: { type: Array, default: () => [] },
})
const emit = defineEmits(['save', 'close'])
const selectedTags = reactive(new Set(props.currentTags))
const newTag = ref('')
function toggleTag(tag) {
if (selectedTags.has(tag)) {
selectedTags.delete(tag)
} else {
selectedTags.add(tag)
}
}
function addNewTag() {
const tag = newTag.value.trim()
if (!tag) return
if (!selectedTags.has(tag)) {
selectedTags.add(tag)
}
newTag.value = ''
}
function save() {
emit('save', [...selectedTags])
}
</script>
<style scoped>
.tagpicker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 6000;
display: flex;
align-items: center;
justify-content: center;
}
.tagpicker-card {
background: #fff;
border-radius: 16px;
padding: 24px;
width: 380px;
max-width: 90vw;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.tagpicker-title {
font-size: 16px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 16px;
}
.tagpicker-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
min-height: 32px;
}
.tagpicker-pill {
font-size: 13px;
padding: 5px 14px;
border-radius: 14px;
background: #f0ece4;
color: #8a7e6b;
cursor: pointer;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.tagpicker-pill.selected {
background: #4a9d7e;
color: #fff;
}
.tagpicker-pill:hover {
opacity: 0.85;
}
.tagpicker-new {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.tagpicker-input {
flex: 1;
padding: 9px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 13px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.tagpicker-input:focus {
border-color: #4a9d7e;
}
.tagpicker-add-btn {
width: 36px;
height: 36px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
background: #fff;
font-size: 18px;
color: #4a9d7e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.tagpicker-add-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tagpicker-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.tagpicker-btn-cancel {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 24px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
}
.tagpicker-btn-cancel:hover {
background: #f8f7f5;
}
.tagpicker-btn-confirm {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 24px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.tagpicker-btn-confirm:hover {
opacity: 0.9;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<div class="usermenu-overlay" @click.self="$emit('close')">
<div class="usermenu-card">
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div>
<div class="usermenu-role">
<span class="role-badge">{{ roleLabel }}</span>
</div>
<div class="usermenu-actions">
<button class="usermenu-btn" @click="goMyDiary">
📖 我的
</button>
<button class="usermenu-btn" @click="goNotifications">
🔔 通知
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
</button>
<button class="usermenu-btn usermenu-btn-logout" @click="handleLogout">
🚪 退出登录
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
const emit = defineEmits(['close'])
const auth = useAuthStore()
const ui = useUiStore()
const router = useRouter()
const unreadCount = ref(0)
const roleLabel = computed(() => {
const map = {
admin: '管理员',
senior_editor: '高级编辑',
editor: '编辑',
viewer: '查看者',
}
return map[auth.user.role] || auth.user.role
})
function goMyDiary() {
emit('close')
router.push('/mydiary')
}
function goNotifications() {
emit('close')
router.push('/notifications')
}
function handleLogout() {
auth.logout()
ui.showToast('已退出登录')
emit('close')
window.location.reload()
}
</script>
<style scoped>
.usermenu-overlay {
position: fixed;
inset: 0;
z-index: 4000;
}
.usermenu-card {
position: absolute;
top: 56px;
right: 16px;
background: #fff;
border-radius: 14px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
padding: 18px 20px 14px;
min-width: 180px;
z-index: 4001;
}
.usermenu-name {
font-size: 16px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 4px;
}
.usermenu-role {
margin-bottom: 14px;
}
.role-badge {
display: inline-block;
font-size: 11px;
padding: 2px 10px;
border-radius: 8px;
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
color: #4a9d7e;
font-weight: 500;
}
.usermenu-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.usermenu-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 9px 10px;
border: none;
background: none;
border-radius: 8px;
font-size: 14px;
color: #3e3a44;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background 0.15s;
position: relative;
}
.usermenu-btn:hover {
background: #f5f3f0;
}
.usermenu-btn-logout {
color: #d9534f;
margin-top: 6px;
border-top: 1px solid #eee;
padding-top: 12px;
border-radius: 0 0 8px 8px;
}
.unread-badge {
background: #d9534f;
color: #fff;
font-size: 11px;
font-weight: 600;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
padding: 0 5px;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,44 @@
const API_BASE = '' // same origin, uses vite proxy in dev
export function getToken() {
return localStorage.getItem('oil_auth_token') || ''
}
export function setToken(token) {
if (token) localStorage.setItem('oil_auth_token', token)
else localStorage.removeItem('oil_auth_token')
}
function buildHeaders(extra = {}) {
const headers = { 'Content-Type': 'application/json', ...extra }
const token = getToken()
if (token) headers['Authorization'] = 'Bearer ' + token
return headers
}
async function request(path, opts = {}) {
const headers = buildHeaders(opts.headers)
const res = await fetch(API_BASE + path, { ...opts, headers })
return res
}
async function requestJSON(path, opts = {}) {
const res = await request(path, opts)
if (!res.ok) throw res
return res.json()
}
// api is callable as api(path, opts) → raw Response
// AND has convenience methods: api.get(), api.post(), api.put(), api.delete()
function apiFn(path, opts = {}) {
return request(path, opts)
}
apiFn.raw = request
apiFn.get = (path) => requestJSON(path)
apiFn.post = (path, body) => requestJSON(path, { method: 'POST', body: JSON.stringify(body) })
apiFn.put = (path, body) => requestJSON(path, { method: 'PUT', body: JSON.stringify(body) })
apiFn.del = (path) => requestJSON(path, { method: 'DELETE' })
apiFn.delete = (path) => requestJSON(path, { method: 'DELETE' })
export const api = apiFn

View File

@@ -0,0 +1,43 @@
import { reactive } from 'vue'
export const dialogState = reactive({
visible: false,
type: 'alert', // 'alert', 'confirm', 'prompt'
message: '',
defaultValue: '',
resolve: null
})
export function showAlert(msg) {
return new Promise(resolve => {
dialogState.visible = true
dialogState.type = 'alert'
dialogState.message = msg
dialogState.resolve = resolve
})
}
export function showConfirm(msg) {
return new Promise(resolve => {
dialogState.visible = true
dialogState.type = 'confirm'
dialogState.message = msg
dialogState.resolve = resolve
})
}
export function showPrompt(msg, defaultVal = '') {
return new Promise(resolve => {
dialogState.visible = true
dialogState.type = 'prompt'
dialogState.message = msg
dialogState.defaultValue = defaultVal
dialogState.resolve = resolve
})
}
export function closeDialog(result) {
dialogState.visible = false
if (dialogState.resolve) dialogState.resolve(result)
dialogState.resolve = null
}

View File

@@ -0,0 +1,42 @@
// Oil English names map
const OIL_EN = {
'薰衣草': 'Lavender', '茶树': 'Tea Tree', '乳香': 'Frankincense',
'柠檬': 'Lemon', '椒样薄荷': 'Peppermint', '丝柏': 'Cypress',
'尤加利': 'Eucalyptus', '迷迭香': 'Rosemary', '天竺葵': 'Geranium',
'依兰依兰': 'Ylang Ylang', '佛手柑': 'Bergamot', '生姜': 'Ginger',
'没药': 'Myrrh', '檀香': 'Sandalwood', '雪松': 'Cedarwood',
'罗马洋甘菊': 'Roman Chamomile', '永久花': 'Helichrysum',
'快乐鼠尾草': 'Clary Sage', '广藿香': 'Patchouli',
'百里香': 'Thyme', '牛至': 'Oregano', '冬青': 'Wintergreen',
'肉桂': 'Cinnamon', '丁香': 'Clove', '黑胡椒': 'Black Pepper',
'葡萄柚': 'Grapefruit', '橙花': 'Neroli', '玫瑰': 'Rose',
'岩兰草': 'Vetiver', '马郁兰': 'Marjoram', '芫荽': 'Coriander',
'柠檬草': 'Lemongrass', '杜松浆果': 'Juniper Berry', '甜橙': 'Wild Orange',
'香茅': 'Citronella', '薄荷': 'Peppermint', '扁柏': 'Arborvitae',
'古巴香脂': 'Copaiba', '椰子油': 'Coconut Oil',
'芳香调理': 'AromaTouch', '保卫复方': 'On Guard',
'乐活复方': 'Balance', '舒缓复方': 'Past Tense',
'净化复方': 'Purify', '呼吸复方': 'Breathe',
'舒压复方': 'Adaptiv', '多特瑞': 'doTERRA',
}
export function oilEn(name) {
return OIL_EN[name] || ''
}
export function recipeNameEn(name) {
// Try to translate known keywords
// Simple approach: return original name for now, user can customize
return name
}
// Custom translations (can be set by admin)
const customTranslations = {}
export function setCustomTranslation(zhName, enName) {
customTranslations[zhName] = enName
}
export function getCustomTranslation(zhName) {
return customTranslations[zhName]
}

View File

@@ -0,0 +1,262 @@
export const DROPS_PER_ML = 18.6
export const OIL_HOMOPHONES = {
'相貌':'香茅','香矛':'香茅','向茅':'香茅','像茅':'香茅',
'如香':'乳香','儒香':'乳香',
'古巴想':'古巴香脂','古巴香':'古巴香脂','古巴相脂':'古巴香脂',
'博荷':'薄荷','薄河':'薄荷',
'尤佳利':'尤加利','优加利':'尤加利',
'依兰':'依兰依兰',
'雪松木':'雪松',
'桧木':'扁柏','桧柏':'扁柏',
'永久化':'永久花','永久华':'永久花',
'罗马洋柑菊':'罗马洋甘菊','洋甘菊':'罗马洋甘菊',
'天竹葵':'天竺葵','天竺癸':'天竺葵',
'没要':'没药','莫药':'没药',
'快乐鼠尾':'快乐鼠尾草',
'椒样博荷':'椒样薄荷','椒样薄和':'椒样薄荷',
'丝柏木':'丝柏',
'柠檬草油':'柠檬草',
'茶树油':'茶树',
'薰衣草油':'薰衣草',
'玫瑰花':'玫瑰',
}
/**
* Levenshtein edit distance between two strings
*/
export function editDistance(a, b) {
const m = a.length, n = b.length
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
}
return dp[m][n]
}
/**
* Fuzzy match oil name against known list.
* Priority: homophone -> exact -> substring -> missing-char -> edit distance
* Returns matched oil name or null.
*/
export function findOil(input, oilNames) {
if (!input || input.length === 0) return null
const trimmed = input.trim()
if (!trimmed) return null
// 1. Homophone alias check
if (OIL_HOMOPHONES[trimmed]) {
const alias = OIL_HOMOPHONES[trimmed]
if (oilNames.includes(alias)) return alias
}
// 2. Exact match
if (oilNames.includes(trimmed)) return trimmed
// 3. Substring match (input ⊂ name or name ⊂ input), prefer longest
let substringMatches = []
for (const name of oilNames) {
if (name.includes(trimmed) || trimmed.includes(name)) {
substringMatches.push(name)
}
}
if (substringMatches.length > 0) {
substringMatches.sort((a, b) => b.length - a.length)
return substringMatches[0]
}
// 4. "Missing one char" match - input is one char shorter than an oil name
for (const name of oilNames) {
if (Math.abs(name.length - trimmed.length) === 1) {
const longer = name.length > trimmed.length ? name : trimmed
const shorter = name.length > trimmed.length ? trimmed : name
// Check if shorter can be formed by removing one char from longer
for (let i = 0; i < longer.length; i++) {
const candidate = longer.slice(0, i) + longer.slice(i + 1)
if (candidate === shorter) return name
}
}
}
// 5. Edit distance fuzzy match
let bestMatch = null
let bestDist = Infinity
for (const name of oilNames) {
const dist = editDistance(trimmed, name)
const maxLen = Math.max(trimmed.length, name.length)
// Only accept if edit distance is reasonable (less than half the length)
if (dist < bestDist && dist <= Math.floor(maxLen / 2)) {
bestDist = dist
bestMatch = name
}
}
return bestMatch
}
/**
* Greedy longest-match from concatenated string against oil names.
* Returns array of matched oil names in order.
*/
export function greedyMatchOils(text, oilNames) {
const results = []
let i = 0
while (i < text.length) {
let bestMatch = null
let bestLen = 0
// Try all oil names sorted by length (longest first)
const sorted = [...oilNames].sort((a, b) => b.length - a.length)
for (const name of sorted) {
if (text.substring(i, i + name.length) === name) {
bestMatch = name
bestLen = name.length
break
}
}
// Also check homophones
if (!bestMatch) {
for (const [alias, canonical] of Object.entries(OIL_HOMOPHONES)) {
if (text.substring(i, i + alias.length) === alias) {
if (!bestMatch || alias.length > bestLen) {
bestMatch = canonical
bestLen = alias.length
}
}
}
}
if (bestMatch) {
results.push(bestMatch)
i += bestLen
} else {
i++
}
}
return results
}
/**
* Parse text chunk into [{oil, drops}] pairs.
* Handles formats like "芳香调理8永久花10" or "薰衣草 3滴 茶树 2ml"
*/
export function parseOilChunk(text, oilNames) {
const results = []
const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g
let match
while ((match = regex.exec(text)) !== null) {
const namePart = match[1].trim()
let amount = parseFloat(match[2])
const unit = match[3] || ''
// Convert ml to drops
if (unit && (unit.toLowerCase() === 'ml' || unit === '毫升')) {
amount = Math.round(amount * 20)
}
// Try greedy match on the name part
const matched = greedyMatchOils(namePart, oilNames)
if (matched.length > 0) {
// Last matched oil gets the drops
for (let i = 0; i < matched.length - 1; i++) {
results.push({ oil: matched[i], drops: 0 })
}
results.push({ oil: matched[matched.length - 1], drops: amount })
} else {
// Try findOil as fallback
const found = findOil(namePart, oilNames)
if (found) {
results.push({ oil: found, drops: amount })
} else if (namePart) {
results.push({ oil: namePart, drops: amount, notFound: true })
}
}
}
return results
}
/**
* Split multi-recipe input by blank lines or semicolons.
* Detects recipe boundaries (non-oil text after seeing oils = new recipe).
*/
export function splitRawIntoBlocks(raw, oilNames) {
// First split by semicolons
let parts = raw.split(/[;]/)
// Then split each part by blank lines
let blocks = []
for (const part of parts) {
const subBlocks = part.split(/\n\s*\n/)
blocks.push(...subBlocks)
}
// Filter empty blocks
blocks = blocks.map(b => b.trim()).filter(b => b.length > 0)
return blocks
}
/**
* Parse one recipe block into {name, ingredients, notFound}.
* 1. Split by commas/newlines/etc
* 2. First non-oil, non-number part = recipe name
* 3. Rest parsed through parseOilChunk
* 4. Deduplicate ingredients
*/
export function parseSingleBlock(raw, oilNames) {
// Split by commas, Chinese commas, newlines, spaces
const parts = raw.split(/[,\n\r]+/).map(s => s.trim()).filter(s => s)
let name = ''
let ingredientParts = []
let foundFirstOil = false
for (const part of parts) {
// Check if this part contains oil references
const hasNumber = /\d/.test(part)
const hasOil = oilNames.some(oil => part.includes(oil)) ||
Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias))
if (!foundFirstOil && !hasOil && !hasNumber && !name) {
// This is the recipe name
name = part
} else {
foundFirstOil = true
ingredientParts.push(part)
}
}
// Parse all ingredient parts
const allIngredients = []
const notFound = []
for (const part of ingredientParts) {
const parsed = parseOilChunk(part, oilNames)
for (const item of parsed) {
if (item.notFound) {
notFound.push(item.oil)
} else {
allIngredients.push(item)
}
}
}
// Deduplicate: merge same oil, sum drops
const deduped = []
const seen = {}
for (const item of allIngredients) {
if (seen[item.oil] !== undefined) {
deduped[seen[item.oil]].drops += item.drops
} else {
seen[item.oil] = deduped.length
deduped.push({ ...item })
}
}
return {
name: name || '未命名配方',
ingredients: deduped,
notFound
}
}

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,56 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'RecipeSearch',
component: () => import('../views/RecipeSearch.vue'),
},
{
path: '/manage',
name: 'RecipeManager',
component: () => import('../views/RecipeManager.vue'),
},
{
path: '/inventory',
name: 'Inventory',
component: () => import('../views/Inventory.vue'),
},
{
path: '/oils',
name: 'OilReference',
component: () => import('../views/OilReference.vue'),
},
{
path: '/projects',
name: 'Projects',
component: () => import('../views/Projects.vue'),
},
{
path: '/mydiary',
name: 'MyDiary',
component: () => import('../views/MyDiary.vue'),
},
{
path: '/audit',
name: 'AuditLog',
component: () => import('../views/AuditLog.vue'),
},
{
path: '/bugs',
name: 'BugTracker',
component: () => import('../views/BugTracker.vue'),
},
{
path: '/users',
name: 'UserManagement',
component: () => import('../views/UserManagement.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

103
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,103 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../composables/useApi'
const DEFAULT_USER = {
id: null,
role: 'viewer',
username: 'anonymous',
display_name: '匿名',
has_password: false,
business_verified: false,
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('oil_auth_token') || '')
const user = ref({ ...DEFAULT_USER })
// Getters
const isLoggedIn = computed(() => user.value.id !== null)
const isAdmin = computed(() => user.value.role === 'admin')
const canEdit = computed(() =>
['editor', 'senior_editor', 'admin'].includes(user.value.role)
)
const isBusiness = computed(() => user.value.business_verified)
// Actions
async function initToken() {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
token.value = urlToken
localStorage.setItem('oil_auth_token', urlToken)
// Clean URL
const url = new URL(window.location)
url.searchParams.delete('token')
window.history.replaceState({}, '', url)
}
if (token.value) {
await loadMe()
}
}
async function loadMe() {
try {
const data = await api.get('/api/me')
user.value = {
id: data.id,
role: data.role,
username: data.username,
display_name: data.display_name,
has_password: data.has_password ?? false,
business_verified: data.business_verified ?? false,
}
} catch {
logout()
}
}
async function login(username, password) {
const data = await api.post('/api/login', { username, password })
token.value = data.token
localStorage.setItem('oil_auth_token', data.token)
await loadMe()
}
async function register(username, password, displayName) {
const data = await api.post('/api/register', {
username,
password,
display_name: displayName,
})
token.value = data.token
localStorage.setItem('oil_auth_token', data.token)
await loadMe()
}
function logout() {
token.value = ''
localStorage.removeItem('oil_auth_token')
user.value = { ...DEFAULT_USER }
}
function canEditRecipe(recipe) {
if (isAdmin.value || user.value.role === 'senior_editor') return true
if (user.value.role === 'editor' && recipe._owner_id === user.value.id) return true
return false
}
return {
token,
user,
isLoggedIn,
isAdmin,
canEdit,
isBusiness,
initToken,
loadMe,
login,
register,
logout,
canEditRecipe,
}
})

View File

@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../composables/useApi'
export const useDiaryStore = defineStore('diary', () => {
const userDiary = ref([])
const currentDiaryId = ref(null)
// Actions
async function loadDiary() {
const data = await api.get('/api/diary')
userDiary.value = data
}
async function createDiary(data) {
const result = await api.post('/api/diary', data)
await loadDiary()
return result
}
async function updateDiary(id, data) {
const result = await api.put(`/api/diary/${id}`, data)
await loadDiary()
return result
}
async function deleteDiary(id) {
await api.delete(`/api/diary/${id}`)
userDiary.value = userDiary.value.filter((d) => (d._id ?? d.id) !== id)
if (currentDiaryId.value === id) {
currentDiaryId.value = null
}
}
async function addEntry(diaryId, content) {
const result = await api.post(`/api/diary/${diaryId}/entries`, content)
await loadDiary()
return result
}
async function deleteEntry(entryId) {
await api.delete(`/api/diary/entries/${entryId}`)
await loadDiary()
}
return {
userDiary,
currentDiaryId,
loadDiary,
createDiary,
updateDiary,
deleteDiary,
addEntry,
deleteEntry,
}
})

106
frontend/src/stores/oils.js Normal file
View File

@@ -0,0 +1,106 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../composables/useApi'
export const DROPS_PER_ML = 18.6
export const VOLUME_DROPS = {
'2.5': 46,
'5': 93,
'10': 186,
'15': 280,
'115': 2146,
}
export const useOilsStore = defineStore('oils', () => {
const oils = ref(new Map())
const oilsMeta = ref(new Map())
// Getters
const oilNames = computed(() =>
[...oils.value.keys()].sort((a, b) => a.localeCompare(b, 'zh'))
)
function pricePerDrop(name) {
return oils.value.get(name) || 0
}
function calcCost(ingredients) {
return ingredients.reduce((sum, ing) => {
return sum + pricePerDrop(ing.oil) * ing.drops
}, 0)
}
function calcRetailCost(ingredients) {
return ingredients.reduce((sum, ing) => {
const meta = oilsMeta.value.get(ing.oil)
if (meta && meta.retailPrice && meta.dropCount) {
return sum + (meta.retailPrice / meta.dropCount) * ing.drops
}
return sum + pricePerDrop(ing.oil) * ing.drops
}, 0)
}
function fmtPrice(n) {
return '¥ ' + n.toFixed(2)
}
function fmtCostWithRetail(ingredients) {
const cost = calcCost(ingredients)
const retail = calcRetailCost(ingredients)
const costStr = fmtPrice(cost)
if (retail > cost) {
return { cost: costStr, retail: fmtPrice(retail), hasRetail: true }
}
return { cost: costStr, retail: null, hasRetail: false }
}
// Actions
async function loadOils() {
const data = await api.get('/api/oils')
const newOils = new Map()
const newMeta = new Map()
for (const oil of data) {
const ppd = oil.drop_count ? oil.bottle_price / oil.drop_count : 0
newOils.set(oil.name, ppd)
newMeta.set(oil.name, {
bottlePrice: oil.bottle_price,
dropCount: oil.drop_count,
retailPrice: oil.retail_price ?? null,
isActive: oil.is_active ?? true,
})
}
oils.value = newOils
oilsMeta.value = newMeta
}
async function saveOil(name, bottlePrice, dropCount, retailPrice) {
await api.post('/api/oils', {
name,
bottle_price: bottlePrice,
drop_count: dropCount,
retail_price: retailPrice,
})
await loadOils()
}
async function deleteOil(name) {
await api.delete(`/api/oils/${encodeURIComponent(name)}`)
oils.value.delete(name)
oilsMeta.value.delete(name)
}
return {
oils,
oilsMeta,
oilNames,
pricePerDrop,
calcCost,
calcRetailCost,
fmtPrice,
fmtCostWithRetail,
loadOils,
saveOil,
deleteOil,
}
})

View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../composables/useApi'
export const useRecipesStore = defineStore('recipes', () => {
const recipes = ref([])
const allTags = ref([])
const userFavorites = ref([])
// Actions
async function loadRecipes() {
const data = await api.get('/api/recipes')
recipes.value = data.map((r) => ({
_id: r._id ?? r.id,
_owner_id: r._owner_id ?? r.owner_id,
_owner_name: r._owner_name ?? r.owner_name ?? '',
_version: r._version ?? r.version ?? 1,
name: r.name,
note: r.note ?? '',
tags: r.tags ?? [],
ingredients: (r.ingredients ?? []).map((ing) => ({
oil: ing.oil ?? ing.name,
drops: ing.drops,
})),
}))
}
async function loadTags() {
const data = await api.get('/api/tags')
const apiTags = data.map((t) => (typeof t === 'string' ? t : t.name))
const recipeTags = recipes.value.flatMap((r) => r.tags)
const tagSet = new Set([...apiTags, ...recipeTags])
allTags.value = [...tagSet].sort((a, b) => a.localeCompare(b, 'zh'))
}
async function loadFavorites() {
try {
const data = await api.get('/api/favorites')
userFavorites.value = data.map((f) => f._id ?? f.id ?? f.recipe_id ?? f)
} catch {
userFavorites.value = []
}
}
async function saveRecipe(recipe) {
if (recipe._id) {
const data = await api.put(`/api/recipes/${recipe._id}`, recipe)
const idx = recipes.value.findIndex((r) => r._id === recipe._id)
if (idx !== -1) {
recipes.value[idx] = { ...recipes.value[idx], ...recipe, _version: data._version ?? data.version ?? recipe._version }
}
return data
} else {
const data = await api.post('/api/recipes', recipe)
await loadRecipes()
return data
}
}
async function deleteRecipe(id) {
await api.delete(`/api/recipes/${id}`)
recipes.value = recipes.value.filter((r) => r._id !== id)
}
async function toggleFavorite(recipeId) {
if (userFavorites.value.includes(recipeId)) {
await api.delete(`/api/favorites/${recipeId}`)
userFavorites.value = userFavorites.value.filter((id) => id !== recipeId)
} else {
await api.post(`/api/favorites/${recipeId}`)
userFavorites.value.push(recipeId)
}
}
function isFavorite(recipe) {
return userFavorites.value.includes(recipe._id)
}
async function createTag(name) {
await api.post('/api/tags', { name })
if (!allTags.value.includes(name)) {
allTags.value = [...allTags.value, name].sort((a, b) => a.localeCompare(b, 'zh'))
}
}
async function deleteTag(name) {
await api.delete(`/api/tags/${encodeURIComponent(name)}`)
allTags.value = allTags.value.filter((t) => t !== name)
}
return {
recipes,
allTags,
userFavorites,
loadRecipes,
loadTags,
loadFavorites,
saveRecipe,
deleteRecipe,
toggleFavorite,
isFavorite,
createTag,
deleteTag,
}
})

40
frontend/src/stores/ui.js Normal file
View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const currentSection = ref('search')
const showLoginModal = ref(false)
const toasts = ref([])
let toastId = 0
function showSection(name) {
currentSection.value = name
}
function showToast(msg, duration = 1800) {
const id = ++toastId
toasts.value.push({ id, msg })
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, duration)
}
function openLogin() {
showLoginModal.value = true
}
function closeLogin() {
showLoginModal.value = false
}
return {
currentSection,
showLoginModal,
toasts,
showSection,
showToast,
openLogin,
closeLogin,
}
})

View File

@@ -0,0 +1,380 @@
<template>
<div class="audit-log">
<h3 class="page-title">📜 操作日志</h3>
<!-- Action Type Filters -->
<div class="filter-row">
<span class="filter-label">操作类型:</span>
<button
v-for="action in actionTypes"
:key="action.value"
class="filter-btn"
:class="{ active: selectedAction === action.value }"
@click="selectedAction = selectedAction === action.value ? '' : action.value"
>{{ action.label }}</button>
</div>
<!-- User Filters -->
<div class="filter-row" v-if="uniqueUsers.length > 0">
<span class="filter-label">用户:</span>
<button
v-for="u in uniqueUsers"
:key="u"
class="filter-btn"
:class="{ active: selectedUser === u }"
@click="selectedUser = selectedUser === u ? '' : u"
>{{ u }}</button>
</div>
<!-- Log List -->
<div class="log-list">
<div v-for="log in filteredLogs" :key="log._id || log.id" class="log-item">
<div class="log-header">
<span class="log-action" :class="actionClass(log.action)">{{ actionLabel(log.action) }}</span>
<span class="log-user">{{ log.user_name || log.username || '系统' }}</span>
<span class="log-time">{{ formatTime(log.created_at) }}</span>
</div>
<div class="log-detail">
<span v-if="log.target_type" class="log-target">{{ log.target_type }}: </span>
<span class="log-desc">{{ log.description || log.detail || formatDetail(log) }}</span>
</div>
<div v-if="log.changes" class="log-changes">
<pre class="changes-pre">{{ typeof log.changes === 'string' ? log.changes : JSON.stringify(log.changes, null, 2) }}</pre>
</div>
<button
v-if="log.undoable"
class="btn-undo"
@click="undoLog(log)"
> 撤销</button>
</div>
<div v-if="filteredLogs.length === 0" class="empty-hint">暂无日志记录</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="load-more">
<button class="btn-outline" @click="loadMore" :disabled="loading">
{{ loading ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
const logs = ref([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(0)
const pageSize = 50
const selectedAction = ref('')
const selectedUser = ref('')
const actionTypes = [
{ value: 'create', label: '创建' },
{ value: 'update', label: '更新' },
{ value: 'delete', label: '删除' },
{ value: 'login', label: '登录' },
{ value: 'approve', label: '审核' },
{ value: 'export', label: '导出' },
]
const uniqueUsers = computed(() => {
const names = new Set()
for (const log of logs.value) {
const name = log.user_name || log.username
if (name) names.add(name)
}
return [...names].sort()
})
const filteredLogs = computed(() => {
let result = logs.value
if (selectedAction.value) {
result = result.filter(l => l.action === selectedAction.value)
}
if (selectedUser.value) {
result = result.filter(l =>
(l.user_name || l.username) === selectedUser.value
)
}
return result
})
function actionLabel(action) {
const map = {
create: '创建',
update: '更新',
delete: '删除',
login: '登录',
approve: '审核',
reject: '拒绝',
export: '导出',
undo: '撤销',
}
return map[action] || action
}
function actionClass(action) {
return {
'action-create': action === 'create',
'action-update': action === 'update',
'action-delete': action === 'delete' || action === 'reject',
'action-login': action === 'login',
'action-approve': action === 'approve',
}
}
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function formatDetail(log) {
if (log.target_name) return log.target_name
if (log.recipe_name) return log.recipe_name
if (log.oil_name) return log.oil_name
return ''
}
async function fetchLogs() {
loading.value = true
try {
const res = await api(`/api/audit-logs?offset=${page.value * pageSize}&limit=${pageSize}`)
if (res.ok) {
const data = await res.json()
const items = Array.isArray(data) ? data : data.logs || data.items || []
if (items.length < pageSize) {
hasMore.value = false
}
logs.value.push(...items)
}
} catch {
hasMore.value = false
}
loading.value = false
}
function loadMore() {
page.value++
fetchLogs()
}
async function undoLog(log) {
const ok = await showConfirm('确定撤销此操作?')
if (!ok) return
try {
const id = log._id || log.id
const res = await api(`/api/audit-logs/${id}/undo`, { method: 'POST' })
if (res.ok) {
ui.showToast('已撤销')
// Refresh
logs.value = []
page.value = 0
hasMore.value = true
await fetchLogs()
} else {
ui.showToast('撤销失败')
}
} catch {
ui.showToast('撤销失败')
}
}
onMounted(() => {
fetchLogs()
})
</script>
<style scoped>
.audit-log {
padding: 0 12px 24px;
}
.page-title {
margin: 0 0 16px;
font-size: 16px;
color: #3e3a44;
}
.filter-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.filter-label {
font-size: 13px;
color: #6b6375;
font-weight: 500;
white-space: nowrap;
}
.filter-btn {
padding: 5px 14px;
border-radius: 16px;
border: 1.5px solid #e5e4e7;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
transition: all 0.15s;
}
.filter-btn.active {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
.filter-btn:hover {
border-color: #d4cfc7;
}
.log-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.log-item {
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
transition: border-color 0.15s;
}
.log-item:hover {
border-color: #d4cfc7;
}
.log-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.log-action {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #f0eeeb;
color: #6b6375;
}
.action-create { background: #e8f5e9; color: #2e7d5a; }
.action-update { background: #e3f2fd; color: #1565c0; }
.action-delete { background: #ffebee; color: #c62828; }
.action-login { background: #fff3e0; color: #e65100; }
.action-approve { background: #f3e5f5; color: #7b1fa2; }
.log-user {
font-size: 13px;
font-weight: 500;
color: #3e3a44;
}
.log-time {
font-size: 11px;
color: #b0aab5;
margin-left: auto;
}
.log-detail {
font-size: 13px;
color: #6b6375;
margin-top: 2px;
}
.log-target {
font-weight: 500;
color: #3e3a44;
}
.log-changes {
margin-top: 6px;
}
.changes-pre {
font-size: 11px;
background: #f8f7f5;
padding: 8px 10px;
border-radius: 6px;
overflow-x: auto;
margin: 0;
color: #6b6375;
font-family: ui-monospace, Consolas, monospace;
line-height: 1.5;
max-height: 120px;
}
.btn-undo {
margin-top: 8px;
padding: 4px 12px;
border: 1.5px solid #e5e4e7;
border-radius: 8px;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
}
.btn-undo:hover {
border-color: #7ec6a4;
color: #4a9d7e;
}
.load-more {
text-align: center;
margin-top: 16px;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 28px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-outline:disabled {
opacity: 0.5;
cursor: default;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 32px 0;
}
</style>

View File

@@ -0,0 +1,644 @@
<template>
<div class="bug-tracker">
<div class="toolbar">
<h3 class="page-title">🐛 Bug Tracker</h3>
<button class="btn-primary" @click="showAddBug = true">+ 新增Bug</button>
</div>
<!-- Active Bugs -->
<div class="section-header">
<span>🔴 活跃 ({{ activeBugs.length }})</span>
</div>
<div class="bug-list">
<div v-for="bug in activeBugs" :key="bug._id || bug.id" class="bug-card" :class="'priority-' + (bug.priority || 'normal')">
<div class="bug-header">
<span class="bug-priority" :class="'p-' + (bug.priority || 'normal')">{{ priorityLabel(bug.priority) }}</span>
<span class="bug-status" :class="'s-' + (bug.status || 'open')">{{ statusLabel(bug.status) }}</span>
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
</div>
<div class="bug-title">{{ bug.title }}</div>
<div v-if="bug.description" class="bug-desc">{{ bug.description }}</div>
<div v-if="bug.reporter" class="bug-reporter">报告者: {{ bug.reporter }}</div>
<!-- Status workflow -->
<div class="bug-actions">
<template v-if="bug.status === 'open'">
<button class="btn-sm btn-status" @click="updateStatus(bug, 'testing')">开始测试</button>
</template>
<template v-else-if="bug.status === 'testing'">
<button class="btn-sm btn-status" @click="updateStatus(bug, 'fixed')">标记修复</button>
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
</template>
<template v-else-if="bug.status === 'fixed'">
<button class="btn-sm btn-status" @click="updateStatus(bug, 'tested')">验证通过</button>
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
</template>
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
</div>
<!-- Comments -->
<div class="comments-section" v-if="expandedBugId === (bug._id || bug.id)">
<div v-for="comment in (bug.comments || [])" :key="comment._id || comment.id" class="comment-item">
<div class="comment-meta">
<span class="comment-author">{{ comment.author || comment.user_name || '匿名' }}</span>
<span class="comment-time">{{ formatDate(comment.created_at) }}</span>
</div>
<div class="comment-text">{{ comment.text || comment.content }}</div>
</div>
<div class="comment-add">
<input
v-model="newComment"
class="form-input"
placeholder="添加备注..."
@keydown.enter="addComment(bug)"
/>
<button class="btn-primary btn-sm" @click="addComment(bug)" :disabled="!newComment.trim()">发送</button>
</div>
</div>
<button class="btn-toggle-comments" @click="toggleComments(bug)">
💬 {{ (bug.comments || []).length }} 条备注
{{ expandedBugId === (bug._id || bug.id) ? '' : '' }}
</button>
</div>
<div v-if="activeBugs.length === 0" class="empty-hint">暂无活跃Bug</div>
</div>
<!-- Resolved Bugs -->
<div class="section-header" style="margin-top:20px" @click="showResolved = !showResolved">
<span> 已解决 ({{ resolvedBugs.length }})</span>
<span class="toggle-icon">{{ showResolved ? '▾' : '▸' }}</span>
</div>
<div v-if="showResolved" class="bug-list">
<div v-for="bug in resolvedBugs" :key="bug._id || bug.id" class="bug-card resolved">
<div class="bug-header">
<span class="bug-priority" :class="'p-' + (bug.priority || 'normal')">{{ priorityLabel(bug.priority) }}</span>
<span class="bug-status s-tested">已解决</span>
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
</div>
<div class="bug-title">{{ bug.title }}</div>
<div class="bug-actions">
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
</div>
</div>
<div v-if="resolvedBugs.length === 0" class="empty-hint">暂无已解决Bug</div>
</div>
<!-- Add Bug Modal -->
<div v-if="showAddBug" class="overlay" @click.self="showAddBug = false">
<div class="overlay-panel">
<div class="overlay-header">
<h3>新增Bug</h3>
<button class="btn-close" @click="showAddBug = false"></button>
</div>
<div class="form-group">
<label>标题</label>
<input v-model="bugForm.title" class="form-input" placeholder="Bug标题" />
</div>
<div class="form-group">
<label>描述</label>
<textarea v-model="bugForm.description" class="form-textarea" rows="4" placeholder="Bug描述复现步骤等..."></textarea>
</div>
<div class="form-group">
<label>优先级</label>
<div class="priority-btns">
<button
v-for="p in priorities"
:key="p.value"
class="priority-btn"
:class="{ active: bugForm.priority === p.value, ['p-' + p.value]: true }"
@click="bugForm.priority = p.value"
>{{ p.label }}</button>
</div>
</div>
<div class="overlay-footer">
<button class="btn-outline" @click="showAddBug = false">取消</button>
<button class="btn-primary" @click="createBug" :disabled="!bugForm.title.trim()">提交</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
const bugs = ref([])
const showAddBug = ref(false)
const showResolved = ref(false)
const expandedBugId = ref(null)
const newComment = ref('')
const bugForm = reactive({
title: '',
description: '',
priority: 'normal',
})
const priorities = [
{ value: 'low', label: '低' },
{ value: 'normal', label: '中' },
{ value: 'high', label: '高' },
{ value: 'critical', label: '紧急' },
]
const activeBugs = computed(() =>
bugs.value.filter(b => b.status !== 'tested' && b.status !== 'closed')
.sort((a, b) => {
const order = { critical: 0, high: 1, normal: 2, low: 3 }
return (order[a.priority] ?? 2) - (order[b.priority] ?? 2)
})
)
const resolvedBugs = computed(() =>
bugs.value.filter(b => b.status === 'tested' || b.status === 'closed')
)
function priorityLabel(p) {
const map = { low: '低', normal: '中', high: '高', critical: '紧急' }
return map[p] || '中'
}
function statusLabel(s) {
const map = { open: '待处理', testing: '测试中', fixed: '已修复', tested: '已验证', closed: '已关闭' }
return map[s] || s
}
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function toggleComments(bug) {
const id = bug._id || bug.id
expandedBugId.value = expandedBugId.value === id ? null : id
}
async function loadBugs() {
try {
const res = await api('/api/bugs')
if (res.ok) {
bugs.value = await res.json()
}
} catch {
bugs.value = []
}
}
async function createBug() {
if (!bugForm.title.trim()) return
try {
const res = await api('/api/bugs', {
method: 'POST',
body: JSON.stringify({
title: bugForm.title.trim(),
description: bugForm.description.trim(),
priority: bugForm.priority,
status: 'open',
reporter: auth.user.display_name || auth.user.username,
}),
})
if (res.ok) {
showAddBug.value = false
bugForm.title = ''
bugForm.description = ''
bugForm.priority = 'normal'
await loadBugs()
ui.showToast('Bug已提交')
}
} catch {
ui.showToast('提交失败')
}
}
async function updateStatus(bug, newStatus) {
const id = bug._id || bug.id
try {
const res = await api(`/api/bugs/${id}`, {
method: 'PUT',
body: JSON.stringify({ status: newStatus }),
})
if (res.ok) {
bug.status = newStatus
ui.showToast(`状态已更新: ${statusLabel(newStatus)}`)
}
} catch {
ui.showToast('更新失败')
}
}
async function removeBug(bug) {
const ok = await showConfirm(`确定删除 "${bug.title}"`)
if (!ok) return
const id = bug._id || bug.id
try {
const res = await api(`/api/bugs/${id}`, { method: 'DELETE' })
if (res.ok) {
bugs.value = bugs.value.filter(b => (b._id || b.id) !== id)
ui.showToast('已删除')
}
} catch {
ui.showToast('删除失败')
}
}
async function addComment(bug) {
if (!newComment.value.trim()) return
const id = bug._id || bug.id
try {
const res = await api(`/api/bugs/${id}/comments`, {
method: 'POST',
body: JSON.stringify({
text: newComment.value.trim(),
author: auth.user.display_name || auth.user.username,
}),
})
if (res.ok) {
newComment.value = ''
await loadBugs()
ui.showToast('备注已添加')
}
} catch {
ui.showToast('添加失败')
}
}
onMounted(() => {
loadBugs()
})
</script>
<style scoped>
.bug-tracker {
padding: 0 12px 24px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 4px;
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 8px;
cursor: pointer;
}
.toggle-icon {
font-size: 12px;
color: #999;
}
.bug-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bug-card {
padding: 14px 16px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
border-left: 4px solid #e5e4e7;
}
.bug-card.priority-critical { border-left-color: #d32f2f; }
.bug-card.priority-high { border-left-color: #f57c00; }
.bug-card.priority-normal { border-left-color: #1976d2; }
.bug-card.priority-low { border-left-color: #9e9e9e; }
.bug-card.resolved { opacity: 0.7; }
.bug-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.bug-priority {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.p-critical { background: #ffebee; color: #c62828; }
.p-high { background: #fff3e0; color: #e65100; }
.p-normal { background: #e3f2fd; color: #1565c0; }
.p-low { background: #f5f5f5; color: #757575; }
.bug-status {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.s-open { background: #ffebee; color: #c62828; }
.s-testing { background: #fff3e0; color: #e65100; }
.s-fixed { background: #e3f2fd; color: #1565c0; }
.s-tested { background: #e8f5e9; color: #2e7d5a; }
.bug-date {
font-size: 11px;
color: #b0aab5;
margin-left: auto;
}
.bug-title {
font-weight: 600;
font-size: 15px;
color: #3e3a44;
margin-bottom: 4px;
}
.bug-desc {
font-size: 13px;
color: #6b6375;
line-height: 1.6;
margin-bottom: 4px;
}
.bug-reporter {
font-size: 12px;
color: #b0aab5;
margin-bottom: 6px;
}
.bug-actions {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.btn-sm {
padding: 5px 14px;
font-size: 12px;
border-radius: 8px;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-status {
background: linear-gradient(135deg, #7ec6a4, #4a9d7e);
color: #fff;
}
.btn-reopen {
background: #fff3e0;
color: #e65100;
border: 1.5px solid #ffe0b2;
}
.btn-delete {
background: #fff;
color: #ef5350;
border: 1.5px solid #ffcdd2;
}
.btn-toggle-comments {
border: none;
background: transparent;
color: #6b6375;
font-size: 12px;
cursor: pointer;
padding: 6px 0;
font-family: inherit;
margin-top: 4px;
}
.btn-toggle-comments:hover {
color: #3e3a44;
}
/* Comments */
.comments-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f0eeeb;
}
.comment-item {
padding: 8px 10px;
background: #f8f7f5;
border-radius: 8px;
margin-bottom: 6px;
}
.comment-meta {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.comment-author {
font-size: 12px;
font-weight: 600;
color: #3e3a44;
}
.comment-time {
font-size: 11px;
color: #b0aab5;
}
.comment-text {
font-size: 13px;
color: #6b6375;
line-height: 1.5;
}
.comment-add {
display: flex;
gap: 6px;
margin-top: 8px;
}
.form-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.overlay-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 480px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.overlay-header h3 {
margin: 0;
font-size: 17px;
color: #3e3a44;
}
.overlay-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 6px;
}
.form-textarea {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.form-textarea:focus {
border-color: #7ec6a4;
}
.priority-btns {
display: flex;
gap: 6px;
}
.priority-btn {
padding: 6px 16px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.priority-btn.active {
font-weight: 600;
}
.priority-btn.active.p-low { background: #f5f5f5; border-color: #9e9e9e; color: #616161; }
.priority-btn.active.p-normal { background: #e3f2fd; border-color: #64b5f6; color: #1565c0; }
.priority-btn.active.p-high { background: #fff3e0; border-color: #ffb74d; color: #e65100; }
.priority-btn.active.p-critical { background: #ffebee; border-color: #ef9a9a; color: #c62828; }
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<div class="inventory-page">
<!-- Search -->
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索精油..."
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<!-- Oil Picker Grid -->
<div class="section-label">点击添加到库存</div>
<div class="oil-picker-grid">
<div
v-for="name in filteredOilNames"
:key="name"
class="oil-pick-chip"
:class="{ owned: ownedSet.has(name) }"
@click="toggleOil(name)"
>
<span class="pick-dot" :class="{ active: ownedSet.has(name) }">{{ ownedSet.has(name) ? '✓' : '+' }}</span>
<span class="pick-name">{{ name }}</span>
</div>
<div v-if="filteredOilNames.length === 0" class="empty-hint">未找到精油</div>
</div>
<!-- Owned Oils Section -->
<div class="section-header">
<span>🧴 已有精油 ({{ ownedOils.length }})</span>
<button v-if="ownedOils.length" class="btn-sm btn-outline" @click="clearAll">清空</button>
</div>
<div v-if="ownedOils.length" class="owned-grid">
<div v-for="name in ownedOils" :key="name" class="owned-chip" @click="toggleOil(name)">
{{ name }}
</div>
</div>
<div v-else class="empty-hint">暂未添加精油点击上方精油添加到库存</div>
<!-- Matching Recipes Section -->
<div class="section-header" style="margin-top:20px">
<span>📋 可做的配方 ({{ matchingRecipes.length }})</span>
</div>
<div v-if="matchingRecipes.length" class="matching-list">
<div v-for="r in matchingRecipes" :key="r._id" class="match-card">
<div class="match-name">{{ r.name }}</div>
<div class="match-ings">
<span
v-for="ing in r.ingredients"
:key="ing.oil"
class="match-ing"
:class="{ missing: !ownedSet.has(ing.oil) }"
>
{{ ing.oil }} {{ ing.drops }}
</span>
</div>
<div class="match-meta">
<span class="match-coverage">覆盖 {{ coveragePercent(r) }}%</span>
<span v-if="missingOils(r).length" class="match-missing">
缺少: {{ missingOils(r).join(', ') }}
</span>
</div>
</div>
</div>
<div v-else class="empty-hint">
{{ ownedOils.length ? '暂无完全匹配的配方' : '添加精油后自动推荐配方' }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const searchQuery = ref('')
const ownedOils = ref([])
const loading = ref(false)
const ownedSet = computed(() => new Set(ownedOils.value))
const filteredOilNames = computed(() => {
if (!searchQuery.value.trim()) return oils.oilNames
const q = searchQuery.value.trim().toLowerCase()
return oils.oilNames.filter(n => n.toLowerCase().includes(q))
})
const matchingRecipes = computed(() => {
if (ownedOils.value.length === 0) return []
return recipeStore.recipes
.filter(r => {
const needed = r.ingredients.map(i => i.oil)
const coverage = needed.filter(o => ownedSet.value.has(o)).length
return coverage >= Math.ceil(needed.length * 0.5)
})
.sort((a, b) => {
const aCov = coverageRatio(a)
const bCov = coverageRatio(b)
return bCov - aCov
})
})
function coverageRatio(recipe) {
const needed = recipe.ingredients.map(i => i.oil)
if (needed.length === 0) return 0
return needed.filter(o => ownedSet.value.has(o)).length / needed.length
}
function coveragePercent(recipe) {
return Math.round(coverageRatio(recipe) * 100)
}
function missingOils(recipe) {
return recipe.ingredients
.map(i => i.oil)
.filter(o => !ownedSet.value.has(o))
}
async function loadInventory() {
loading.value = true
try {
const res = await api('/api/inventory')
if (res.ok) {
const data = await res.json()
ownedOils.value = data.oils || data || []
}
} catch {
// inventory may not exist yet
}
loading.value = false
}
async function saveInventory() {
try {
await api('/api/inventory', {
method: 'PUT',
body: JSON.stringify({ oils: ownedOils.value }),
})
} catch {
// silent save
}
}
async function toggleOil(name) {
const idx = ownedOils.value.indexOf(name)
if (idx >= 0) {
ownedOils.value.splice(idx, 1)
} else {
ownedOils.value.push(name)
ownedOils.value.sort((a, b) => a.localeCompare(b, 'zh'))
}
await saveInventory()
}
async function clearAll() {
ownedOils.value = []
await saveInventory()
ui.showToast('已清空库存')
}
onMounted(() => {
loadInventory()
})
</script>
<style scoped>
.inventory-page {
padding: 0 12px 24px;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
margin-bottom: 14px;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.section-label {
font-size: 12px;
color: #b0aab5;
margin-bottom: 8px;
padding: 0 4px;
}
.oil-picker-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
}
.oil-pick-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 20px;
background: #f8f7f5;
border: 1.5px solid #e5e4e7;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
color: #3e3a44;
}
.oil-pick-chip:hover {
border-color: #d4cfc7;
background: #f0eeeb;
}
.oil-pick-chip.owned {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
}
.pick-dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e5e4e7;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #999;
}
.pick-dot.active {
background: #4a9d7e;
color: #fff;
}
.pick-name {
font-size: 13px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 4px;
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 8px;
}
.owned-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.owned-chip {
padding: 6px 12px;
border-radius: 20px;
background: #e8f5e9;
border: 1.5px solid #7ec6a4;
font-size: 13px;
color: #2e7d5a;
cursor: pointer;
transition: all 0.15s;
}
.owned-chip:hover {
background: #ffebee;
border-color: #ef9a9a;
color: #c62828;
}
.matching-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.match-card {
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
}
.match-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 6px;
}
.match-ings {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.match-ing {
padding: 2px 8px;
border-radius: 10px;
background: #e8f5e9;
font-size: 12px;
color: #2e7d5a;
}
.match-ing.missing {
background: #fff3e0;
color: #e65100;
}
.match-meta {
display: flex;
gap: 10px;
align-items: center;
font-size: 12px;
}
.match-coverage {
color: #4a9d7e;
font-weight: 600;
}
.match-missing {
color: #999;
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
border-radius: 8px;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,872 @@
<template>
<div class="my-diary">
<!-- Sub Tabs -->
<div class="sub-tabs">
<button class="sub-tab" :class="{ active: activeTab === 'diary' }" @click="activeTab = 'diary'">📖 配方日记</button>
<button class="sub-tab" :class="{ active: activeTab === 'brand' }" @click="activeTab = 'brand'">🏷 Brand</button>
<button class="sub-tab" :class="{ active: activeTab === 'account' }" @click="activeTab = 'account'">👤 Account</button>
</div>
<!-- Diary Tab -->
<div v-if="activeTab === 'diary'" class="tab-content">
<!-- Smart Paste -->
<div class="paste-section">
<textarea
v-model="pasteText"
class="paste-input"
placeholder="粘贴配方文本,智能识别...&#10;例如: 舒缓配方薰衣草3滴茶树2滴"
rows="3"
></textarea>
<button class="btn-primary" @click="handleSmartPaste" :disabled="!pasteText.trim()">智能添加</button>
</div>
<!-- Diary Recipe Grid -->
<div class="diary-grid">
<div
v-for="d in diaryStore.userDiary"
:key="d._id || d.id"
class="diary-card"
:class="{ selected: selectedDiaryId === (d._id || d.id) }"
@click="selectDiary(d)"
>
<div class="diary-name">{{ d.name || '未命名' }}</div>
<div class="diary-ings">
<span v-for="ing in (d.ingredients || []).slice(0, 3)" :key="ing.oil" class="diary-ing">
{{ ing.oil }} {{ ing.drops }}
</span>
<span v-if="(d.ingredients || []).length > 3" class="diary-more">+{{ (d.ingredients || []).length - 3 }}</span>
</div>
<div class="diary-meta">
<span class="diary-cost" v-if="d.ingredients">{{ oils.fmtPrice(oils.calcCost(d.ingredients)) }}</span>
<span class="diary-entries">{{ (d.entries || []).length }} 条日志</span>
</div>
</div>
<div v-if="diaryStore.userDiary.length === 0" class="empty-hint">暂无配方日记</div>
</div>
<!-- Diary Detail Panel -->
<div v-if="selectedDiary" class="diary-detail">
<div class="detail-header">
<input v-model="selectedDiary.name" class="detail-name-input" @blur="updateCurrentDiary" />
<button class="btn-icon" @click="deleteDiary(selectedDiary)" title="删除">🗑</button>
</div>
<!-- Ingredients Editor -->
<div class="section-card">
<h4>成分</h4>
<div v-for="(ing, i) in selectedDiary.ingredients" :key="i" class="ing-row">
<select v-model="ing.oil" class="form-select" @change="updateCurrentDiary">
<option value="">选择精油</option>
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<input
v-model.number="ing.drops"
type="number"
min="0"
class="form-input-sm"
@change="updateCurrentDiary"
/>
<button class="btn-icon-sm" @click="selectedDiary.ingredients.splice(i, 1); updateCurrentDiary()"></button>
</div>
<button class="btn-outline btn-sm" @click="selectedDiary.ingredients.push({ oil: '', drops: 1 })">+ 添加</button>
</div>
<!-- Notes -->
<div class="section-card">
<h4>备注</h4>
<textarea
v-model="selectedDiary.note"
class="form-textarea"
rows="2"
placeholder="配方备注..."
@blur="updateCurrentDiary"
></textarea>
</div>
<!-- Journal Entries -->
<div class="section-card">
<h4>使用日志</h4>
<div class="entry-list">
<div v-for="entry in (selectedDiary.entries || [])" :key="entry._id || entry.id" class="entry-item">
<div class="entry-date">{{ formatDate(entry.created_at) }}</div>
<div class="entry-content">{{ entry.content || entry.text }}</div>
<button class="btn-icon-sm" @click="removeEntry(entry)"></button>
</div>
</div>
<div class="entry-add">
<input
v-model="newEntryText"
class="form-input"
placeholder="记录使用感受..."
@keydown.enter="addNewEntry"
/>
<button class="btn-primary btn-sm" @click="addNewEntry" :disabled="!newEntryText.trim()">添加</button>
</div>
</div>
</div>
</div>
<!-- Brand Tab -->
<div v-if="activeTab === 'brand'" class="tab-content">
<div class="section-card">
<h4>🏷 品牌设置</h4>
<div class="form-group">
<label>品牌名称</label>
<input v-model="brandName" class="form-input" placeholder="您的品牌名称" @blur="saveBrandSettings" />
</div>
<div class="form-group">
<label>二维码链接</label>
<input v-model="brandQrUrl" class="form-input" placeholder="https://..." @blur="saveBrandSettings" />
<div v-if="brandQrUrl" class="qr-preview">
<img :src="'https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=' + encodeURIComponent(brandQrUrl)" alt="QR" class="qr-img" />
</div>
</div>
<div class="form-group">
<label>品牌Logo</label>
<div class="upload-area" @click="triggerUpload('logo')">
<img v-if="brandLogo" :src="brandLogo" class="upload-preview" />
<span v-else class="upload-hint">点击上传Logo</span>
</div>
<input ref="logoInput" type="file" accept="image/*" style="display:none" @change="handleUpload('logo', $event)" />
</div>
<div class="form-group">
<label>卡片背景</label>
<div class="upload-area" @click="triggerUpload('bg')">
<img v-if="brandBg" :src="brandBg" class="upload-preview wide" />
<span v-else class="upload-hint">点击上传背景图</span>
</div>
<input ref="bgInput" type="file" accept="image/*" style="display:none" @change="handleUpload('bg', $event)" />
</div>
</div>
</div>
<!-- Account Tab -->
<div v-if="activeTab === 'account'" class="tab-content">
<div class="section-card">
<h4>👤 账号设置</h4>
<div class="form-group">
<label>显示名称</label>
<input v-model="displayName" class="form-input" />
<button class="btn-primary btn-sm" style="margin-top:6px" @click="updateDisplayName">保存</button>
</div>
<div class="form-group">
<label>用户名</label>
<div class="form-static">{{ auth.user.username }}</div>
</div>
<div class="form-group">
<label>角色</label>
<div class="form-static role-badge">{{ roleLabel }}</div>
</div>
</div>
<div class="section-card">
<h4>🔑 修改密码</h4>
<div class="form-group">
<label>{{ auth.user.has_password ? '当前密码' : '(首次设置密码)' }}</label>
<input v-if="auth.user.has_password" v-model="oldPassword" type="password" class="form-input" placeholder="当前密码" />
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" class="form-input" placeholder="新密码" />
</div>
<div class="form-group">
<label>确认密码</label>
<input v-model="confirmPassword" type="password" class="form-input" placeholder="确认新密码" />
</div>
<button class="btn-primary" @click="changePassword">修改密码</button>
</div>
<!-- Business Verification -->
<div v-if="!auth.isBusiness" class="section-card">
<h4>💼 商业认证</h4>
<p class="hint-text">申请商业认证后可使用商业核算功能</p>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的申请理由..."></textarea>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessReason.trim()">提交申请</button>
</div>
<div v-else class="section-card">
<h4>💼 商业认证</h4>
<div class="verified-badge"> 已认证商业用户</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useDiaryStore } from '../stores/diary'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showAlert } from '../composables/useDialog'
import { parseSingleBlock } from '../composables/useSmartPaste'
const auth = useAuthStore()
const oils = useOilsStore()
const diaryStore = useDiaryStore()
const ui = useUiStore()
const activeTab = ref('diary')
const pasteText = ref('')
const selectedDiaryId = ref(null)
const selectedDiary = ref(null)
const newEntryText = ref('')
// Brand settings
const brandName = ref('')
const brandQrUrl = ref('')
const brandLogo = ref('')
const brandBg = ref('')
const logoInput = ref(null)
const bgInput = ref(null)
// Account settings
const displayName = ref('')
const oldPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const businessReason = ref('')
const roleLabel = computed(() => {
const roles = {
admin: '管理员',
senior_editor: '高级编辑',
editor: '编辑',
viewer: '查看者',
}
return roles[auth.user.role] || auth.user.role
})
onMounted(async () => {
await diaryStore.loadDiary()
displayName.value = auth.user.display_name || ''
await loadBrandSettings()
})
function selectDiary(d) {
const id = d._id || d.id
selectedDiaryId.value = id
selectedDiary.value = {
...d,
_id: id,
ingredients: (d.ingredients || []).map(i => ({ ...i })),
entries: d.entries || [],
note: d.note || '',
}
}
async function handleSmartPaste() {
const result = parseSingleBlock(pasteText.value, oils.oilNames)
try {
await diaryStore.createDiary({
name: result.name,
ingredients: result.ingredients,
note: '',
})
pasteText.value = ''
ui.showToast('已添加配方日记')
if (result.notFound.length > 0) {
ui.showToast(`未识别: ${result.notFound.join(', ')}`)
}
} catch (e) {
ui.showToast('添加失败')
}
}
async function updateCurrentDiary() {
if (!selectedDiary.value) return
try {
await diaryStore.updateDiary(selectedDiary.value._id, {
name: selectedDiary.value.name,
ingredients: selectedDiary.value.ingredients.filter(i => i.oil),
note: selectedDiary.value.note,
})
} catch {
// silent
}
}
async function deleteDiary(d) {
const ok = await showConfirm(`确定删除 "${d.name}"`)
if (!ok) return
await diaryStore.deleteDiary(d._id)
selectedDiary.value = null
selectedDiaryId.value = null
ui.showToast('已删除')
}
async function addNewEntry() {
if (!newEntryText.value.trim() || !selectedDiary.value) return
try {
await diaryStore.addEntry(selectedDiary.value._id, {
text: newEntryText.value.trim(),
})
newEntryText.value = ''
// Refresh diary to get new entries
const updated = diaryStore.userDiary.find(d => (d._id || d.id) === selectedDiary.value._id)
if (updated) selectDiary(updated)
ui.showToast('已添加日志')
} catch {
ui.showToast('添加失败')
}
}
async function removeEntry(entry) {
const ok = await showConfirm('确定删除此日志?')
if (!ok) return
try {
await diaryStore.deleteEntry(entry._id || entry.id)
const updated = diaryStore.userDiary.find(d => (d._id || d.id) === selectedDiary.value._id)
if (updated) selectDiary(updated)
} catch {
ui.showToast('删除失败')
}
}
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
// Brand settings
async function loadBrandSettings() {
try {
const res = await api('/api/brand-settings')
if (res.ok) {
const data = await res.json()
brandName.value = data.brand_name || ''
brandQrUrl.value = data.qr_url || ''
brandLogo.value = data.logo_url || ''
brandBg.value = data.bg_url || ''
}
} catch {
// no brand settings yet
}
}
async function saveBrandSettings() {
try {
await api('/api/brand-settings', {
method: 'PUT',
body: JSON.stringify({
brand_name: brandName.value,
qr_url: brandQrUrl.value,
}),
})
} catch {
// silent
}
}
function triggerUpload(type) {
if (type === 'logo') logoInput.value?.click()
else bgInput.value?.click()
}
async function handleUpload(type, event) {
const file = event.target.files[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
formData.append('type', type)
try {
const token = localStorage.getItem('oil_auth_token') || ''
const res = await fetch('/api/brand-upload', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: formData,
})
if (res.ok) {
const data = await res.json()
if (type === 'logo') brandLogo.value = data.url
else brandBg.value = data.url
ui.showToast('上传成功')
}
} catch {
ui.showToast('上传失败')
}
}
// Account
async function updateDisplayName() {
try {
await api('/api/me/display-name', {
method: 'PUT',
body: JSON.stringify({ display_name: displayName.value }),
})
auth.user.display_name = displayName.value
ui.showToast('已更新')
} catch {
ui.showToast('更新失败')
}
}
async function changePassword() {
if (newPassword.value !== confirmPassword.value) {
await showAlert('两次密码输入不一致')
return
}
if (newPassword.value.length < 4) {
await showAlert('密码至少4个字符')
return
}
try {
await api('/api/me/password', {
method: 'PUT',
body: JSON.stringify({
old_password: oldPassword.value,
new_password: newPassword.value,
}),
})
oldPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
ui.showToast('密码已修改')
await auth.loadMe()
} catch {
ui.showToast('修改失败')
}
}
async function applyBusiness() {
try {
await api('/api/business-apply', {
method: 'POST',
body: JSON.stringify({ reason: businessReason.value }),
})
businessReason.value = ''
ui.showToast('申请已提交,请等待审核')
} catch {
ui.showToast('提交失败')
}
}
</script>
<style scoped>
.my-diary {
padding: 0 12px 24px;
}
.sub-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
background: #f8f7f5;
border-radius: 12px;
padding: 4px;
}
.sub-tab {
flex: 1;
border: none;
background: transparent;
padding: 10px 8px;
border-radius: 10px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
color: #6b6375;
transition: all 0.15s;
font-weight: 500;
}
.sub-tab.active {
background: #fff;
color: #3e3a44;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.tab-content {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.paste-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.paste-input {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.paste-input:focus {
border-color: #7ec6a4;
}
.diary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.diary-card {
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.diary-card:hover {
border-color: #d4cfc7;
}
.diary-card.selected {
border-color: #7ec6a4;
background: #f0faf5;
}
.diary-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 6px;
}
.diary-ings {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.diary-ing {
padding: 2px 8px;
background: #f0eeeb;
border-radius: 10px;
font-size: 11px;
color: #6b6375;
}
.diary-more {
font-size: 11px;
color: #b0aab5;
}
.diary-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.diary-cost {
color: #4a9d7e;
font-weight: 500;
}
.diary-entries {
color: #b0aab5;
}
/* Detail panel */
.diary-detail {
margin-top: 16px;
padding: 16px;
background: #f8f7f5;
border-radius: 14px;
border: 1.5px solid #e5e4e7;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.detail-name-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
outline: none;
}
.detail-name-input:focus {
border-color: #7ec6a4;
}
.section-card {
margin-bottom: 14px;
padding: 12px;
background: #fff;
border-radius: 10px;
border: 1.5px solid #e5e4e7;
}
.section-card h4 {
margin: 0 0 10px;
font-size: 13px;
color: #3e3a44;
}
.ing-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.form-select {
flex: 1;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.form-input-sm {
width: 60px;
padding: 8px 8px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.form-textarea {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.entry-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.entry-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: #f8f7f5;
border-radius: 8px;
}
.entry-date {
font-size: 11px;
color: #b0aab5;
white-space: nowrap;
margin-top: 2px;
}
.entry-content {
flex: 1;
font-size: 13px;
color: #3e3a44;
line-height: 1.5;
}
.entry-add {
display: flex;
gap: 6px;
}
.form-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
/* Brand */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 6px;
}
.form-static {
padding: 8px 12px;
background: #f0eeeb;
border-radius: 8px;
font-size: 14px;
color: #6b6375;
}
.role-badge {
display: inline-block;
}
.qr-preview {
margin-top: 10px;
text-align: center;
}
.qr-img {
width: 120px;
height: 120px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
}
.upload-area {
width: 100%;
min-height: 80px;
border: 2px dashed #d4cfc7;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.15s;
overflow: hidden;
}
.upload-area:hover {
border-color: #7ec6a4;
}
.upload-preview {
max-width: 80px;
max-height: 80px;
object-fit: contain;
}
.upload-preview.wide {
max-width: 200px;
max-height: 100px;
}
.upload-hint {
font-size: 13px;
color: #b0aab5;
}
.hint-text {
font-size: 13px;
color: #6b6375;
margin-bottom: 12px;
}
.verified-badge {
padding: 12px;
background: #e8f5e9;
border-radius: 10px;
color: #2e7d5a;
font-weight: 500;
text-align: center;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-sm {
padding: 6px 14px;
font-size: 12px;
border-radius: 8px;
}
.btn-icon {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
padding: 4px;
}
.btn-icon-sm {
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
padding: 2px;
color: #999;
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.diary-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,788 @@
<template>
<div class="oil-reference">
<!-- Knowledge Cards -->
<div class="knowledge-cards">
<div class="kcard" @click="showDilution = true">
<span class="kcard-icon">💧</span>
<span class="kcard-title">稀释比例</span>
<span class="kcard-arrow"></span>
</div>
<div class="kcard" @click="showContra = true">
<span class="kcard-icon"></span>
<span class="kcard-title">使用禁忌</span>
<span class="kcard-arrow"></span>
</div>
</div>
<!-- Info Overlays -->
<div v-if="showDilution" class="info-overlay" @click.self="showDilution = false">
<div class="info-panel">
<div class="info-header">
<h3>💧 稀释比例参考</h3>
<button class="btn-close" @click="showDilution = false"></button>
</div>
<div class="info-body">
<table class="info-table">
<thead>
<tr><th>用途</th><th>比例</th><th>每10ml基底油</th></tr>
</thead>
<tbody>
<tr><td>面部护肤</td><td>1%</td><td>2滴精油</td></tr>
<tr><td>身体按摩</td><td>2-3%</td><td>4-6滴精油</td></tr>
<tr><td>局部疼痛</td><td>3-5%</td><td>6-10滴精油</td></tr>
<tr><td>急救用途</td><td>5-10%</td><td>10-20滴精油</td></tr>
<tr><td>儿童(2-6)</td><td>0.5-1%</td><td>1-2滴精油</td></tr>
<tr><td>婴儿(&lt;2)</td><td>0.25%</td><td>0.5滴精油</td></tr>
</tbody>
</table>
<p class="info-note">* 1ml 约等于 {{ DROPS_PER_ML }} </p>
</div>
</div>
</div>
<div v-if="showContra" class="info-overlay" @click.self="showContra = false">
<div class="info-panel">
<div class="info-header">
<h3> 使用禁忌</h3>
<button class="btn-close" @click="showContra = false"></button>
</div>
<div class="info-body">
<div class="contra-section">
<h4>光敏性精油涂抹后12小时内避免阳光直射</h4>
<p>柠檬佛手柑葡萄柚莱姆甜橙野橘</p>
</div>
<div class="contra-section">
<h4>孕妇慎用</h4>
<p>快乐鼠尾草迷迭香肉桂丁香百里香牛至冬青</p>
</div>
<div class="contra-section">
<h4>儿童慎用</h4>
<p>椒样薄荷6岁以下避免尤加利10岁以下慎用冬青肉桂</p>
</div>
<div class="contra-section">
<h4>宠物禁用</h4>
<p>茶树尤加利肉桂丁香百里香冬青对猫有毒</p>
</div>
</div>
</div>
</div>
<!-- Add Oil Form (admin/senior_editor) -->
<div v-if="auth.canEdit" class="add-oil-form">
<h3 class="section-title">添加精油</h3>
<div class="form-row">
<input v-model="newOilName" class="form-input" placeholder="精油名称" />
<input v-model.number="newBottlePrice" class="form-input-sm" type="number" placeholder="瓶价 ¥" />
<input v-model.number="newDropCount" class="form-input-sm" type="number" placeholder="滴数" />
<input v-model.number="newRetailPrice" class="form-input-sm" type="number" placeholder="零售价 ¥" />
<button class="btn-primary" @click="addOil" :disabled="!newOilName.trim()">添加</button>
</div>
</div>
<!-- Search & View Toggle -->
<div class="toolbar">
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索精油..."
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<div class="view-toggle">
<button
class="toggle-btn"
:class="{ active: viewMode === 'bottle' }"
@click="viewMode = 'bottle'"
>🧴 瓶价</button>
<button
class="toggle-btn"
:class="{ active: viewMode === 'drop' }"
@click="viewMode = 'drop'"
>💧 滴价</button>
</div>
</div>
<!-- Oil Grid -->
<div class="oil-grid">
<div
v-for="name in filteredOilNames"
:key="name"
class="oil-card"
@click="selectOil(name)"
>
<div class="oil-name">{{ name }}</div>
<div class="oil-price" v-if="viewMode === 'bottle'">
{{ getMeta(name)?.bottlePrice != null ? ('¥ ' + getMeta(name).bottlePrice.toFixed(2)) : '--' }}
<span class="oil-count" v-if="getMeta(name)?.dropCount">({{ getMeta(name).dropCount }})</span>
</div>
<div class="oil-price" v-else>
{{ oils.pricePerDrop(name) ? ('¥ ' + oils.pricePerDrop(name).toFixed(4)) : '--' }}
<span class="oil-unit">/</span>
</div>
<div v-if="getMeta(name)?.retailPrice" class="oil-retail">
零售 ¥ {{ getMeta(name).retailPrice.toFixed(2) }}
</div>
<div class="oil-actions" v-if="auth.isAdmin" @click.stop>
<button class="btn-icon-sm" @click="editOil(name)" title="编辑"></button>
<button class="btn-icon-sm" @click="removeOil(name)" title="删除">🗑</button>
</div>
</div>
<div v-if="filteredOilNames.length === 0" class="empty-hint">未找到精油</div>
</div>
<!-- Oil Detail Card -->
<div v-if="selectedOilName" class="oil-detail-overlay" @click.self="selectedOilName = null">
<div class="oil-detail-panel">
<div class="detail-header">
<h3>{{ selectedOilName }}</h3>
<button class="btn-close" @click="selectedOilName = null"></button>
</div>
<div class="detail-body">
<div class="detail-row">
<span class="detail-label">瓶价</span>
<span class="detail-value">{{ getMeta(selectedOilName)?.bottlePrice != null ? ('¥ ' + getMeta(selectedOilName).bottlePrice.toFixed(2)) : '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">总滴数</span>
<span class="detail-value">{{ getMeta(selectedOilName)?.dropCount || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">每滴价格</span>
<span class="detail-value">{{ oils.pricePerDrop(selectedOilName) ? ('¥ ' + oils.pricePerDrop(selectedOilName).toFixed(4)) : '--' }}</span>
</div>
<div class="detail-row" v-if="getMeta(selectedOilName)?.retailPrice">
<span class="detail-label">零售价</span>
<span class="detail-value">¥ {{ getMeta(selectedOilName).retailPrice.toFixed(2) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">每ml价格</span>
<span class="detail-value">{{ oils.pricePerDrop(selectedOilName) ? ('¥ ' + (oils.pricePerDrop(selectedOilName) * DROPS_PER_ML).toFixed(2)) : '--' }}</span>
</div>
<h4 style="margin:16px 0 8px">含此精油的配方</h4>
<div v-if="recipesWithOil.length" class="detail-recipes">
<div v-for="r in recipesWithOil" :key="r._id" class="detail-recipe-item">
<span class="dr-name">{{ r.name }}</span>
<span class="dr-drops">{{ getDropsForOil(r, selectedOilName) }}</span>
</div>
</div>
<div v-else class="empty-hint">暂无使用此精油的配方</div>
</div>
</div>
</div>
<!-- Edit Oil Overlay -->
<div v-if="editingOilName" class="info-overlay" @click.self="editingOilName = null">
<div class="info-panel" style="max-width:400px">
<div class="info-header">
<h3>编辑精油: {{ editingOilName }}</h3>
<button class="btn-close" @click="editingOilName = null"></button>
</div>
<div class="info-body">
<div class="form-group">
<label>瓶价 (¥)</label>
<input v-model.number="editBottlePrice" class="form-input" type="number" />
</div>
<div class="form-group">
<label>滴数</label>
<input v-model.number="editDropCount" class="form-input" type="number" />
</div>
<div class="form-group">
<label>零售价 (¥)</label>
<input v-model.number="editRetailPrice" class="form-input" type="number" />
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px">
<button class="btn-outline" @click="editingOilName = null">取消</button>
<button class="btn-primary" @click="saveEditOil">保存</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore, DROPS_PER_ML } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const searchQuery = ref('')
const viewMode = ref('bottle')
const selectedOilName = ref(null)
const showDilution = ref(false)
const showContra = ref(false)
// Add oil form
const newOilName = ref('')
const newBottlePrice = ref(null)
const newDropCount = ref(null)
const newRetailPrice = ref(null)
// Edit oil
const editingOilName = ref(null)
const editBottlePrice = ref(0)
const editDropCount = ref(0)
const editRetailPrice = ref(null)
const filteredOilNames = computed(() => {
if (!searchQuery.value.trim()) return oils.oilNames
const q = searchQuery.value.trim().toLowerCase()
return oils.oilNames.filter(n => n.toLowerCase().includes(q))
})
const recipesWithOil = computed(() => {
if (!selectedOilName.value) return []
return recipeStore.recipes.filter(r =>
r.ingredients.some(i => i.oil === selectedOilName.value)
)
})
function getMeta(name) {
return oils.oilsMeta.get(name)
}
function getDropsForOil(recipe, oilName) {
const ing = recipe.ingredients.find(i => i.oil === oilName)
return ing ? ing.drops : 0
}
function selectOil(name) {
selectedOilName.value = name
}
async function addOil() {
if (!newOilName.value.trim()) return
try {
await oils.saveOil(
newOilName.value.trim(),
newBottlePrice.value || 0,
newDropCount.value || 0,
newRetailPrice.value || null
)
ui.showToast(`已添加: ${newOilName.value}`)
newOilName.value = ''
newBottlePrice.value = null
newDropCount.value = null
newRetailPrice.value = null
} catch (e) {
ui.showToast('添加失败: ' + (e.message || ''))
}
}
function editOil(name) {
editingOilName.value = name
const meta = oils.oilsMeta.get(name)
editBottlePrice.value = meta?.bottlePrice || 0
editDropCount.value = meta?.dropCount || 0
editRetailPrice.value = meta?.retailPrice || null
}
async function saveEditOil() {
try {
await oils.saveOil(
editingOilName.value,
editBottlePrice.value,
editDropCount.value,
editRetailPrice.value
)
ui.showToast('已更新')
editingOilName.value = null
} catch (e) {
ui.showToast('更新失败')
}
}
async function removeOil(name) {
const ok = await showConfirm(`确定删除精油 "${name}"`)
if (!ok) return
try {
await oils.deleteOil(name)
ui.showToast('已删除')
} catch {
ui.showToast('删除失败')
}
}
</script>
<style scoped>
.oil-reference {
padding: 0 12px 24px;
}
.knowledge-cards {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.kcard {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: linear-gradient(135deg, #f8f7f5, #f0eeeb);
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.kcard:hover {
border-color: #7ec6a4;
background: linear-gradient(135deg, #f0faf5, #e8f5e9);
}
.kcard-icon {
font-size: 22px;
}
.kcard-title {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
flex: 1;
}
.kcard-arrow {
color: #b0aab5;
font-size: 18px;
}
/* Info overlay */
.info-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.info-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 520px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.info-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.info-header h3 {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.info-body {
font-size: 14px;
color: #3e3a44;
line-height: 1.7;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
}
.info-table th,
.info-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #e5e4e7;
font-size: 13px;
}
.info-table th {
font-weight: 600;
color: #6b6375;
background: #f8f7f5;
}
.info-note {
font-size: 12px;
color: #b0aab5;
margin-top: 8px;
}
.contra-section {
margin-bottom: 14px;
}
.contra-section h4 {
font-size: 14px;
margin: 0 0 4px;
color: #e65100;
}
.contra-section p {
font-size: 13px;
color: #6b6375;
margin: 0;
}
/* Add oil form */
.add-oil-form {
margin-bottom: 16px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin: 0 0 10px;
}
.form-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.form-input {
flex: 1;
min-width: 100px;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
.form-input-sm {
width: 80px;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 4px;
}
/* Toolbar */
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
align-items: center;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
flex: 1;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.view-toggle {
display: flex;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
overflow: hidden;
}
.toggle-btn {
border: none;
background: #fff;
padding: 8px 14px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
transition: all 0.15s;
white-space: nowrap;
}
.toggle-btn.active {
background: linear-gradient(135deg, #7ec6a4, #4a9d7e);
color: #fff;
}
/* Oil grid */
.oil-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.oil-card {
padding: 12px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
position: relative;
}
.oil-card:hover {
border-color: #7ec6a4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.oil-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 4px;
}
.oil-price {
font-size: 14px;
color: #4a9d7e;
font-weight: 500;
}
.oil-count {
font-size: 11px;
color: #b0aab5;
font-weight: 400;
}
.oil-unit {
font-size: 11px;
color: #b0aab5;
}
.oil-retail {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.oil-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.oil-card:hover .oil-actions {
opacity: 1;
}
.btn-icon-sm {
border: none;
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 6px;
}
.btn-icon-sm:hover {
background: #f0eeeb;
}
/* Detail overlay */
.oil-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.oil-detail-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 440px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.detail-header h3 {
margin: 0;
font-size: 18px;
color: #3e3a44;
}
.detail-body h4 {
font-size: 14px;
color: #3e3a44;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0eeeb;
font-size: 14px;
}
.detail-label {
color: #6b6375;
}
.detail-value {
font-weight: 600;
color: #3e3a44;
}
.detail-recipes {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-recipe-item {
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: #f8f7f5;
border-radius: 8px;
font-size: 13px;
}
.dr-name {
color: #3e3a44;
}
.dr-drops {
color: #4a9d7e;
font-weight: 500;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.oil-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.form-row {
flex-direction: column;
}
.form-input-sm {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,759 @@
<template>
<div class="projects-page">
<!-- Project List -->
<div class="toolbar">
<h3 class="page-title">💼 商业核算</h3>
<button class="btn-primary" @click="createProject">+ 新建项目</button>
</div>
<div v-if="!selectedProject" class="project-list">
<div
v-for="p in projects"
:key="p._id || p.id"
class="project-card"
@click="selectProject(p)"
>
<div class="proj-header">
<span class="proj-name">{{ p.name }}</span>
<span class="proj-date">{{ formatDate(p.updated_at || p.created_at) }}</span>
</div>
<div class="proj-summary">
<span>成分: {{ (p.ingredients || []).length }} </span>
<span class="proj-cost" v-if="p.ingredients && p.ingredients.length">
成本 {{ oils.fmtPrice(oils.calcCost(p.ingredients)) }}
</span>
</div>
<div class="proj-actions" @click.stop>
<button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑</button>
</div>
</div>
<div v-if="projects.length === 0" class="empty-hint">暂无项目点击上方创建</div>
</div>
<!-- Project Detail -->
<div v-if="selectedProject" class="project-detail">
<div class="detail-toolbar">
<button class="btn-back" @click="selectedProject = null">&larr; 返回列表</button>
<input
v-model="selectedProject.name"
class="proj-name-input"
@blur="saveProject"
/>
<button class="btn-outline btn-sm" @click="importFromRecipe">📋 从配方导入</button>
</div>
<!-- Ingredients Editor -->
<div class="ingredients-section">
<h4>🧴 配方成分</h4>
<div v-for="(ing, i) in selectedProject.ingredients" :key="i" class="ing-row">
<select v-model="ing.oil" class="form-select" @change="saveProject">
<option value="">选择精油</option>
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<input
v-model.number="ing.drops"
type="number"
min="0"
class="form-input-sm"
placeholder="滴数"
@change="saveProject"
/>
<span class="ing-cost">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * (ing.drops || 0)) : '--' }}</span>
<button class="btn-icon-sm" @click="removeIngredient(i)"></button>
</div>
<button class="btn-outline btn-sm" @click="addIngredient">+ 添加成分</button>
</div>
<!-- Pricing Section -->
<div class="pricing-section">
<h4>💰 价格计算</h4>
<div class="price-row">
<span class="price-label">原料成本</span>
<span class="price-value cost">{{ oils.fmtPrice(materialCost) }}</span>
</div>
<div class="price-row">
<span class="price-label">包装费用</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.packaging_cost" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row">
<span class="price-label">人工费用</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.labor_cost" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row">
<span class="price-label">其他成本</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.other_cost" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row total">
<span class="price-label">总成本</span>
<span class="price-value cost">{{ oils.fmtPrice(totalCost) }}</span>
</div>
<div class="price-row">
<span class="price-label">售价</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.selling_price" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row">
<span class="price-label">批量数量</span>
<input v-model.number="selectedProject.quantity" type="number" min="1" class="form-input-inline" @change="saveProject" />
</div>
</div>
<!-- Profit Analysis -->
<div class="profit-section">
<h4>📊 利润分析</h4>
<div class="profit-grid">
<div class="profit-card">
<div class="profit-label">单件利润</div>
<div class="profit-value" :class="{ negative: unitProfit < 0 }">{{ oils.fmtPrice(unitProfit) }}</div>
</div>
<div class="profit-card">
<div class="profit-label">利润率</div>
<div class="profit-value" :class="{ negative: profitMargin < 0 }">{{ profitMargin.toFixed(1) }}%</div>
</div>
<div class="profit-card">
<div class="profit-label">批量总利润</div>
<div class="profit-value" :class="{ negative: batchProfit < 0 }">{{ oils.fmtPrice(batchProfit) }}</div>
</div>
<div class="profit-card">
<div class="profit-label">批量总收入</div>
<div class="profit-value">{{ oils.fmtPrice(batchRevenue) }}</div>
</div>
</div>
</div>
<!-- Notes -->
<div class="notes-section">
<h4>📝 备注</h4>
<textarea
v-model="selectedProject.notes"
class="notes-textarea"
rows="3"
placeholder="项目备注..."
@blur="saveProject"
></textarea>
</div>
</div>
<!-- Import From Recipe Modal -->
<div v-if="showImportModal" class="overlay" @click.self="showImportModal = false">
<div class="overlay-panel">
<div class="overlay-header">
<h3>从配方导入</h3>
<button class="btn-close" @click="showImportModal = false"></button>
</div>
<div class="recipe-import-list">
<div
v-for="r in recipeStore.recipes"
:key="r._id"
class="import-item"
@click="doImport(r)"
>
<span class="import-name">{{ r.name }}</span>
<span class="import-count">{{ r.ingredients.length }} 种精油</span>
<span class="import-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const projects = ref([])
const selectedProject = ref(null)
const showImportModal = ref(false)
onMounted(async () => {
await loadProjects()
})
async function loadProjects() {
try {
const res = await api('/api/projects')
if (res.ok) {
projects.value = await res.json()
}
} catch {
projects.value = []
}
}
async function createProject() {
const name = await showPrompt('项目名称:', '新项目')
if (!name) return
try {
const res = await api('/api/projects', {
method: 'POST',
body: JSON.stringify({
name,
ingredients: [],
packaging_cost: 0,
labor_cost: 0,
other_cost: 0,
selling_price: 0,
quantity: 1,
notes: '',
}),
})
if (res.ok) {
await loadProjects()
const data = await res.json()
selectedProject.value = projects.value.find(p => (p._id || p.id) === (data._id || data.id)) || null
ui.showToast('项目已创建')
}
} catch {
ui.showToast('创建失败')
}
}
function selectProject(p) {
selectedProject.value = {
...p,
ingredients: (p.ingredients || []).map(i => ({ ...i })),
packaging_cost: p.packaging_cost || 0,
labor_cost: p.labor_cost || 0,
other_cost: p.other_cost || 0,
selling_price: p.selling_price || 0,
quantity: p.quantity || 1,
notes: p.notes || '',
}
}
async function saveProject() {
if (!selectedProject.value) return
const id = selectedProject.value._id || selectedProject.value.id
try {
await api(`/api/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(selectedProject.value),
})
await loadProjects()
} catch {
// silent save
}
}
async function deleteProject(p) {
const ok = await showConfirm(`确定删除项目 "${p.name}"`)
if (!ok) return
const id = p._id || p.id
try {
await api(`/api/projects/${id}`, { method: 'DELETE' })
projects.value = projects.value.filter(proj => (proj._id || proj.id) !== id)
if (selectedProject.value && (selectedProject.value._id || selectedProject.value.id) === id) {
selectedProject.value = null
}
ui.showToast('已删除')
} catch {
ui.showToast('删除失败')
}
}
function addIngredient() {
if (!selectedProject.value) return
selectedProject.value.ingredients.push({ oil: '', drops: 1 })
}
function removeIngredient(index) {
selectedProject.value.ingredients.splice(index, 1)
saveProject()
}
function importFromRecipe() {
showImportModal.value = true
}
function doImport(recipe) {
if (!selectedProject.value) return
selectedProject.value.ingredients = recipe.ingredients.map(i => ({ ...i }))
showImportModal.value = false
saveProject()
ui.showToast(`已导入 "${recipe.name}" 的配方`)
}
const materialCost = computed(() => {
if (!selectedProject.value) return 0
return oils.calcCost(selectedProject.value.ingredients.filter(i => i.oil))
})
const totalCost = computed(() => {
if (!selectedProject.value) return 0
return materialCost.value +
(selectedProject.value.packaging_cost || 0) +
(selectedProject.value.labor_cost || 0) +
(selectedProject.value.other_cost || 0)
})
const unitProfit = computed(() => {
if (!selectedProject.value) return 0
return (selectedProject.value.selling_price || 0) - totalCost.value
})
const profitMargin = computed(() => {
if (!selectedProject.value || !selectedProject.value.selling_price) return 0
return (unitProfit.value / selectedProject.value.selling_price) * 100
})
const batchProfit = computed(() => {
return unitProfit.value * (selectedProject.value?.quantity || 1)
})
const batchRevenue = computed(() => {
return (selectedProject.value?.selling_price || 0) * (selectedProject.value?.quantity || 1)
})
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleDateString('zh-CN')
}
</script>
<style scoped>
.projects-page {
padding: 0 12px 24px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.project-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.project-card {
padding: 14px 16px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
position: relative;
}
.project-card:hover {
border-color: #7ec6a4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.proj-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.proj-name {
font-weight: 600;
font-size: 15px;
color: #3e3a44;
}
.proj-date {
font-size: 12px;
color: #b0aab5;
}
.proj-summary {
display: flex;
gap: 12px;
font-size: 13px;
color: #6b6375;
}
.proj-cost {
color: #4a9d7e;
font-weight: 500;
}
.proj-actions {
position: absolute;
top: 12px;
right: 12px;
opacity: 0;
transition: opacity 0.15s;
}
.project-card:hover .proj-actions {
opacity: 1;
}
/* Detail */
.detail-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.btn-back {
border: none;
background: #f0eeeb;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
color: #6b6375;
}
.btn-back:hover {
background: #e5e4e7;
}
.proj-name-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
outline: none;
min-width: 120px;
}
.proj-name-input:focus {
border-color: #7ec6a4;
}
.ingredients-section,
.pricing-section,
.profit-section,
.notes-section {
margin-bottom: 20px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.ingredients-section h4,
.pricing-section h4,
.profit-section h4,
.notes-section h4 {
margin: 0 0 12px;
font-size: 14px;
color: #3e3a44;
}
.ing-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.form-select {
flex: 1;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.form-input-sm {
width: 70px;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.ing-cost {
font-size: 13px;
color: #4a9d7e;
font-weight: 500;
min-width: 60px;
text-align: right;
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #eae8e5;
font-size: 14px;
}
.price-row.total {
border-top: 2px solid #d4cfc7;
border-bottom: 2px solid #d4cfc7;
font-weight: 600;
padding: 10px 0;
}
.price-label {
color: #6b6375;
}
.price-value {
font-weight: 600;
color: #3e3a44;
}
.price-value.cost {
color: #4a9d7e;
}
.price-input-wrap {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #3e3a44;
}
.form-input-inline {
width: 80px;
padding: 6px 8px;
border: 1.5px solid #d4cfc7;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
outline: none;
text-align: right;
}
.form-input-inline:focus {
border-color: #7ec6a4;
}
.profit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.profit-card {
padding: 12px;
background: #fff;
border-radius: 10px;
text-align: center;
border: 1.5px solid #e5e4e7;
}
.profit-label {
font-size: 12px;
color: #6b6375;
margin-bottom: 4px;
}
.profit-value {
font-size: 18px;
font-weight: 700;
color: #4a9d7e;
}
.profit-value.negative {
color: #ef5350;
}
.notes-textarea {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.notes-textarea:focus {
border-color: #7ec6a4;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.overlay-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 480px;
width: 100%;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.overlay-header h3 {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.recipe-import-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.import-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
.import-item:hover {
background: #f0faf5;
}
.import-name {
flex: 1;
font-weight: 500;
font-size: 14px;
color: #3e3a44;
}
.import-count {
font-size: 12px;
color: #b0aab5;
}
.import-cost {
font-size: 13px;
color: #4a9d7e;
font-weight: 500;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-sm {
padding: 6px 14px;
font-size: 12px;
border-radius: 8px;
}
.btn-icon-sm {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 6px;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.profit-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,967 @@
<template>
<div class="recipe-manager">
<!-- Review Bar (admin only) -->
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending">
📝 待审核配方: {{ pendingCount }}
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
</div>
<div v-if="showPending && pendingRecipes.length" class="pending-list">
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
<span class="pending-name">{{ r.name }}</span>
<span class="pending-owner">{{ r._owner_name }}</span>
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="manage-toolbar">
<div class="search-box">
<input
class="search-input"
v-model="manageSearch"
placeholder="搜索配方..."
/>
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''"></button>
</div>
<button class="btn-primary" @click="showAddOverlay = true">+ 添加配方</button>
<button class="btn-outline" @click="exportExcel">📊 导出Excel</button>
</div>
<!-- Tag Filter Bar -->
<div class="tag-filter-bar">
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
🏷 标签筛选 {{ showTagFilter ? '' : '' }}
</button>
<div v-if="showTagFilter" class="tag-list">
<span
v-for="tag in recipeStore.allTags"
:key="tag"
class="tag-chip"
:class="{ active: selectedTags.includes(tag) }"
@click="toggleTag(tag)"
>{{ tag }}</span>
</div>
</div>
<!-- Batch Operations -->
<div v-if="selectedIds.size > 0" class="batch-bar">
<span>已选 {{ selectedIds.size }} </span>
<select v-model="batchAction" class="batch-select">
<option value="">批量操作...</option>
<option value="tag">添加标签</option>
<option value="share">分享</option>
<option value="export">导出卡片</option>
<option value="delete">删除</option>
</select>
<button class="btn-sm btn-primary" @click="executeBatch" :disabled="!batchAction">执行</button>
<button class="btn-sm btn-outline" @click="clearSelection">取消选择</button>
</div>
<!-- My Recipes Section -->
<div class="recipe-section">
<h3 class="section-title">📖 我的配方 ({{ myRecipes.length }})</h3>
<div class="recipe-list">
<div
v-for="r in myFilteredRecipes"
:key="r._id"
class="recipe-row"
:class="{ selected: selectedIds.has(r._id) }"
>
<input
type="checkbox"
:checked="selectedIds.has(r._id)"
@change="toggleSelect(r._id)"
class="row-check"
/>
<div class="row-info" @click="editRecipe(r)">
<span class="row-name">{{ r.name }}</span>
<span class="row-tags">
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span>
</span>
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
</div>
<div class="row-actions">
<button class="btn-icon" @click="editRecipe(r)" title="编辑"></button>
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑</button>
</div>
</div>
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
</div>
</div>
<!-- Public Recipes Section -->
<div class="recipe-section">
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
<div class="recipe-list">
<div
v-for="r in publicFilteredRecipes"
:key="r._id"
class="recipe-row"
:class="{ selected: selectedIds.has(r._id) }"
>
<input
type="checkbox"
:checked="selectedIds.has(r._id)"
@change="toggleSelect(r._id)"
class="row-check"
/>
<div class="row-info" @click="editRecipe(r)">
<span class="row-name">{{ r.name }}</span>
<span class="row-owner">{{ r._owner_name }}</span>
<span class="row-tags">
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span>
</span>
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
</div>
<div class="row-actions" v-if="auth.canEditRecipe(r)">
<button class="btn-icon" @click="editRecipe(r)" title="编辑"></button>
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑</button>
</div>
</div>
<div v-if="publicFilteredRecipes.length === 0" class="empty-hint">暂无公共配方</div>
</div>
</div>
<!-- Add/Edit Recipe Overlay -->
<div v-if="showAddOverlay" class="overlay" @click.self="closeOverlay">
<div class="overlay-panel">
<div class="overlay-header">
<h3>{{ editingRecipe ? '编辑配方' : '添加配方' }}</h3>
<button class="btn-close" @click="closeOverlay"></button>
</div>
<!-- Smart Paste Section -->
<div class="paste-section">
<textarea
v-model="smartPasteText"
class="paste-input"
placeholder="粘贴配方文本,支持智能识别...&#10;例如: 薰衣草3滴 茶树2滴"
rows="4"
></textarea>
<button class="btn-primary" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
智能识别
</button>
</div>
<div class="divider-text">或手动输入</div>
<!-- Manual Form -->
<div class="form-group">
<label>配方名称</label>
<input v-model="formName" class="form-input" placeholder="配方名称" />
</div>
<div class="form-group">
<label>成分</label>
<div v-for="(ing, i) in formIngredients" :key="i" class="ing-row">
<select v-model="ing.oil" class="form-select">
<option value="">选择精油</option>
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<input v-model.number="ing.drops" type="number" min="0" class="form-input-sm" placeholder="滴数" />
<button class="btn-icon-sm" @click="formIngredients.splice(i, 1)"></button>
</div>
<button class="btn-outline btn-sm" @click="formIngredients.push({ oil: '', drops: 1 })">+ 添加成分</button>
</div>
<div class="form-group">
<label>备注</label>
<textarea v-model="formNote" class="form-input" rows="2" placeholder="配方备注"></textarea>
</div>
<div class="form-group">
<label>标签</label>
<div class="tag-list">
<span
v-for="tag in recipeStore.allTags"
:key="tag"
class="tag-chip"
:class="{ active: formTags.includes(tag) }"
@click="toggleFormTag(tag)"
>{{ tag }}</span>
</div>
</div>
<div class="overlay-footer">
<button class="btn-outline" @click="closeOverlay">取消</button>
<button class="btn-primary" @click="saveCurrentRecipe">保存</button>
</div>
</div>
</div>
<!-- Tag Picker Overlay -->
<TagPicker
v-if="showTagPicker"
:name="tagPickerName"
:currentTags="tagPickerTags"
:allTags="recipeStore.allTags"
@save="onTagPickerSave"
@close="showTagPicker = false"
/>
</div>
</template>
<script setup>
import { ref, computed, reactive } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog'
import { parseSingleBlock } from '../composables/useSmartPaste'
import RecipeCard from '../components/RecipeCard.vue'
import TagPicker from '../components/TagPicker.vue'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const manageSearch = ref('')
const selectedTags = ref([])
const showTagFilter = ref(false)
const selectedIds = reactive(new Set())
const batchAction = ref('')
const showAddOverlay = ref(false)
const editingRecipe = ref(null)
const showPending = ref(false)
const pendingRecipes = ref([])
const pendingCount = ref(0)
// Form state
const formName = ref('')
const formIngredients = ref([{ oil: '', drops: 1 }])
const formNote = ref('')
const formTags = ref([])
const smartPasteText = ref('')
// Tag picker state
const showTagPicker = ref(false)
const tagPickerName = ref('')
const tagPickerTags = ref([])
// Computed lists
const myRecipes = computed(() =>
recipeStore.recipes.filter(r => r._owner_id === auth.user.id)
)
const publicRecipes = computed(() =>
recipeStore.recipes.filter(r => r._owner_id !== auth.user.id)
)
function filterBySearchAndTags(list) {
let result = list
const q = manageSearch.value.trim().toLowerCase()
if (q) {
result = result.filter(r =>
r.name.toLowerCase().includes(q) ||
r.ingredients.some(ing => ing.oil.toLowerCase().includes(q)) ||
(r.tags && r.tags.some(t => t.toLowerCase().includes(q)))
)
}
if (selectedTags.value.length > 0) {
result = result.filter(r =>
r.tags && selectedTags.value.every(t => r.tags.includes(t))
)
}
return result
}
const myFilteredRecipes = computed(() => filterBySearchAndTags(myRecipes.value))
const publicFilteredRecipes = computed(() => filterBySearchAndTags(publicRecipes.value))
function toggleTag(tag) {
const idx = selectedTags.value.indexOf(tag)
if (idx >= 0) selectedTags.value.splice(idx, 1)
else selectedTags.value.push(tag)
}
function toggleSelect(id) {
if (selectedIds.has(id)) selectedIds.delete(id)
else selectedIds.add(id)
}
function clearSelection() {
selectedIds.clear()
batchAction.value = ''
}
async function executeBatch() {
const ids = [...selectedIds]
if (!ids.length || !batchAction.value) return
if (batchAction.value === 'delete') {
const ok = await showConfirm(`确定删除 ${ids.length} 个配方?`)
if (!ok) return
for (const id of ids) {
await recipeStore.deleteRecipe(id)
}
ui.showToast(`已删除 ${ids.length} 个配方`)
} else if (batchAction.value === 'tag') {
const tagName = await showPrompt('输入要添加的标签:')
if (!tagName) return
for (const id of ids) {
const recipe = recipeStore.recipes.find(r => r._id === id)
if (recipe && !recipe.tags.includes(tagName)) {
recipe.tags.push(tagName)
await recipeStore.saveRecipe(recipe)
}
}
ui.showToast(`已为 ${ids.length} 个配方添加标签`)
} else if (batchAction.value === 'share') {
const text = ids.map(id => {
const r = recipeStore.recipes.find(rec => rec._id === id)
if (!r) return ''
const ings = r.ingredients.map(ing => `${ing.oil} ${ing.drops}`).join('')
return `${r.name}${ings}`
}).filter(Boolean).join('\n\n')
try {
await navigator.clipboard.writeText(text)
ui.showToast('已复制到剪贴板')
} catch {
ui.showToast('复制失败')
}
} else if (batchAction.value === 'export') {
ui.showToast('导出卡片功能开发中')
}
clearSelection()
}
function editRecipe(recipe) {
editingRecipe.value = recipe
formName.value = recipe.name
formIngredients.value = recipe.ingredients.map(i => ({ ...i }))
formNote.value = recipe.note || ''
formTags.value = [...(recipe.tags || [])]
showAddOverlay.value = true
}
function closeOverlay() {
showAddOverlay.value = false
editingRecipe.value = null
resetForm()
}
function resetForm() {
formName.value = ''
formIngredients.value = [{ oil: '', drops: 1 }]
formNote.value = ''
formTags.value = []
smartPasteText.value = ''
}
function handleSmartPaste() {
const result = parseSingleBlock(smartPasteText.value, oils.oilNames)
formName.value = result.name
formIngredients.value = result.ingredients.length > 0
? result.ingredients
: [{ oil: '', drops: 1 }]
if (result.notFound.length > 0) {
ui.showToast(`未识别: ${result.notFound.join(', ')}`)
}
}
function toggleFormTag(tag) {
const idx = formTags.value.indexOf(tag)
if (idx >= 0) formTags.value.splice(idx, 1)
else formTags.value.push(tag)
}
async function saveCurrentRecipe() {
const validIngs = formIngredients.value.filter(i => i.oil && i.drops > 0)
if (!formName.value.trim()) {
ui.showToast('请输入配方名称')
return
}
if (validIngs.length === 0) {
ui.showToast('请至少添加一个成分')
return
}
const payload = {
name: formName.value.trim(),
ingredients: validIngs,
note: formNote.value,
tags: formTags.value,
}
if (editingRecipe.value) {
payload._id = editingRecipe.value._id
payload._version = editingRecipe.value._version
}
try {
await recipeStore.saveRecipe(payload)
ui.showToast(editingRecipe.value ? '配方已更新' : '配方已添加')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
}
async function removeRecipe(recipe) {
const ok = await showConfirm(`确定删除配方 "${recipe.name}"`)
if (!ok) return
try {
await recipeStore.deleteRecipe(recipe._id)
ui.showToast('已删除')
} catch (e) {
ui.showToast('删除失败')
}
}
async function approveRecipe(recipe) {
try {
await api('/api/recipes/' + recipe._id + '/approve', { method: 'POST' })
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
pendingCount.value--
ui.showToast('已通过')
await recipeStore.loadRecipes()
} catch {
ui.showToast('操作失败')
}
}
async function rejectRecipe(recipe) {
try {
await api('/api/recipes/' + recipe._id + '/reject', { method: 'POST' })
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
pendingCount.value--
ui.showToast('已拒绝')
} catch {
ui.showToast('操作失败')
}
}
async function exportExcel() {
try {
const res = await api('/api/recipes/export-excel')
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '配方导出.xlsx'
a.click()
URL.revokeObjectURL(url)
ui.showToast('导出成功')
} catch {
ui.showToast('导出失败')
}
}
function onTagPickerSave(tags) {
formTags.value = tags
showTagPicker.value = false
}
// Load pending if admin
if (auth.isAdmin) {
api('/api/recipes/pending').then(async res => {
if (res.ok) {
const data = await res.json()
pendingRecipes.value = data
pendingCount.value = data.length
}
}).catch(() => {})
}
</script>
<style scoped>
.recipe-manager {
padding: 0 12px 24px;
}
.review-bar {
background: linear-gradient(135deg, #fff3e0, #ffe0b2);
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
color: #e65100;
}
.pending-list {
margin-bottom: 12px;
}
.pending-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #fffde7;
border-radius: 8px;
margin-bottom: 6px;
font-size: 13px;
}
.pending-name {
font-weight: 600;
flex: 1;
}
.pending-owner {
color: #999;
font-size: 12px;
}
.manage-toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
flex: 1;
min-width: 160px;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.tag-filter-bar {
margin-bottom: 12px;
}
.tag-toggle-btn {
background: #f8f7f5;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
color: #3e3a44;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tag-chip {
padding: 4px 12px;
border-radius: 16px;
background: #f0eeeb;
font-size: 12px;
cursor: pointer;
color: #6b6375;
border: 1.5px solid transparent;
transition: all 0.15s;
}
.tag-chip.active {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
.batch-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #e8f5e9;
border-radius: 10px;
margin-bottom: 12px;
font-size: 13px;
flex-wrap: wrap;
}
.batch-select {
padding: 6px 10px;
border-radius: 8px;
border: 1.5px solid #d4cfc7;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.recipe-section {
margin-bottom: 20px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #3e3a44;
margin: 0 0 10px;
}
.recipe-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.recipe-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
transition: all 0.15s;
}
.recipe-row:hover {
border-color: #d4cfc7;
background: #fafaf8;
}
.recipe-row.selected {
border-color: #7ec6a4;
background: #f0faf5;
}
.row-check {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4a9d7e;
}
.row-info {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
min-width: 0;
flex-wrap: wrap;
}
.row-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
}
.row-owner {
font-size: 11px;
color: #b0aab5;
}
.row-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.mini-tag {
padding: 2px 8px;
border-radius: 10px;
background: #f0eeeb;
font-size: 11px;
color: #6b6375;
}
.row-cost {
font-size: 13px;
color: #4a9d7e;
font-weight: 500;
margin-left: auto;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 4px;
}
.btn-icon {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
padding: 4px;
border-radius: 6px;
}
.btn-icon:hover {
background: #f0eeeb;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.overlay-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 520px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.overlay-header h3 {
margin: 0;
font-size: 17px;
color: #3e3a44;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.paste-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.paste-input {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.paste-input:focus {
border-color: #7ec6a4;
}
.divider-text {
text-align: center;
color: #b0aab5;
font-size: 12px;
margin: 12px 0;
position: relative;
}
.divider-text::before,
.divider-text::after {
content: '';
position: absolute;
top: 50%;
width: 35%;
height: 1px;
background: #e5e4e7;
}
.divider-text::before {
left: 0;
}
.divider-text::after {
right: 0;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 10px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.form-input:focus {
border-color: #7ec6a4;
}
.ing-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.form-select {
flex: 1;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.form-input-sm {
width: 70px;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.btn-icon-sm {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #999;
padding: 4px;
}
.overlay-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
white-space: nowrap;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
white-space: nowrap;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-sm {
padding: 6px 14px;
font-size: 12px;
border-radius: 8px;
}
.btn-approve {
background: #4a9d7e;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
}
.btn-reject {
background: #ef5350;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
.toggle-icon {
font-size: 12px;
}
@media (max-width: 600px) {
.manage-toolbar {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,398 @@
<template>
<div class="recipe-search">
<!-- Category Carousel -->
<div class="cat-wrap" v-if="categories.length">
<button class="cat-arrow cat-arrow-left" @click="scrollCat(-1)" :disabled="catScrollPos <= 0">&lsaquo;</button>
<div class="cat-track" ref="catTrack">
<div
v-for="cat in categories"
:key="cat.name"
class="cat-card"
:class="{ active: selectedCategory === cat.name }"
@click="toggleCategory(cat.name)"
>
<span class="cat-icon">{{ cat.icon || '📁' }}</span>
<span class="cat-label">{{ cat.name }}</span>
</div>
</div>
<button class="cat-arrow cat-arrow-right" @click="scrollCat(1)">&rsaquo;</button>
</div>
<!-- Search Box -->
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索配方名、精油、标签..."
@input="onSearch"
/>
<button v-if="searchQuery" class="search-clear-btn" @click="clearSearch"></button>
<button class="search-btn" @click="onSearch">🔍</button>
</div>
<!-- Personal Section (logged in) -->
<div v-if="auth.isLoggedIn" class="personal-section">
<div class="section-header" @click="showMyRecipes = !showMyRecipes">
<span>📖 我的配方</span>
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
</div>
<div v-if="showMyRecipes" class="recipe-grid">
<RecipeCard
v-for="(r, i) in myRecipesPreview"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
<div v-if="myRecipesPreview.length === 0" class="empty-hint">暂无个人配方</div>
</div>
<div class="section-header" @click="showFavorites = !showFavorites">
<span> 收藏配方</span>
<span class="toggle-icon">{{ showFavorites ? '▾' : '▸' }}</span>
</div>
<div v-if="showFavorites" class="recipe-grid">
<RecipeCard
v-for="(r, i) in favoritesPreview"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
<div v-if="favoritesPreview.length === 0" class="empty-hint">暂无收藏配方</div>
</div>
</div>
<!-- Fuzzy Search Results -->
<div v-if="searchQuery && fuzzyResults.length" class="search-results-section">
<div class="section-label">🔍 搜索结果 ({{ fuzzyResults.length }})</div>
<div class="recipe-grid">
<RecipeCard
v-for="(r, i) in fuzzyResults"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
</div>
</div>
<!-- Public Recipe Grid -->
<div v-if="!searchQuery || fuzzyResults.length === 0">
<div class="section-label">🌿 公共配方库 ({{ filteredRecipes.length }})</div>
<div class="recipe-grid">
<RecipeCard
v-for="(r, i) in filteredRecipes"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
<div v-if="filteredRecipes.length === 0" class="empty-hint">暂无配方</div>
</div>
</div>
<!-- Recipe Detail Overlay -->
<RecipeDetailOverlay
v-if="selectedRecipeIndex !== null"
:recipeIndex="selectedRecipeIndex"
@close="selectedRecipeIndex = null"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import RecipeCard from '../components/RecipeCard.vue'
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const searchQuery = ref('')
const selectedCategory = ref(null)
const categories = ref([])
const selectedRecipeIndex = ref(null)
const showMyRecipes = ref(true)
const showFavorites = ref(true)
const catScrollPos = ref(0)
const catTrack = ref(null)
onMounted(async () => {
try {
const res = await api('/api/category-modules')
if (res.ok) {
categories.value = await res.json()
}
} catch {
// category modules are optional
}
})
function toggleCategory(name) {
selectedCategory.value = selectedCategory.value === name ? null : name
}
function scrollCat(dir) {
if (!catTrack.value) return
const scrollAmount = 200
catTrack.value.scrollLeft += dir * scrollAmount
catScrollPos.value = catTrack.value.scrollLeft + dir * scrollAmount
}
const filteredRecipes = computed(() => {
let list = recipeStore.recipes
if (selectedCategory.value) {
list = list.filter(r => r.tags && r.tags.includes(selectedCategory.value))
}
return list
})
const fuzzyResults = computed(() => {
if (!searchQuery.value.trim()) return []
const q = searchQuery.value.trim().toLowerCase()
return recipeStore.recipes.filter(r => {
const nameMatch = r.name.toLowerCase().includes(q)
const oilMatch = r.ingredients.some(ing => ing.oil.toLowerCase().includes(q))
const tagMatch = r.tags && r.tags.some(t => t.toLowerCase().includes(q))
return nameMatch || oilMatch || tagMatch
})
})
const myRecipesPreview = computed(() => {
if (!auth.isLoggedIn) return []
return recipeStore.recipes
.filter(r => r._owner_id === auth.user.id)
.slice(0, 6)
})
const favoritesPreview = computed(() => {
if (!auth.isLoggedIn) return []
return recipeStore.recipes
.filter(r => recipeStore.isFavorite(r))
.slice(0, 6)
})
function findGlobalIndex(recipe) {
return recipeStore.recipes.findIndex(r => r._id === recipe._id)
}
function openDetail(index) {
if (index >= 0) {
selectedRecipeIndex.value = index
}
}
async function handleToggleFav(recipe) {
if (!auth.isLoggedIn) {
ui.openLogin()
return
}
await recipeStore.toggleFavorite(recipe._id)
}
function onSearch() {
// fuzzyResults computed handles the filtering reactively
}
function clearSearch() {
searchQuery.value = ''
selectedCategory.value = null
}
</script>
<style scoped>
.recipe-search {
padding: 0 12px 24px;
}
.cat-wrap {
position: relative;
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 4px;
}
.cat-track {
display: flex;
gap: 10px;
overflow-x: auto;
scroll-behavior: smooth;
flex: 1;
padding: 8px 0;
scrollbar-width: none;
}
.cat-track::-webkit-scrollbar {
display: none;
}
.cat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 16px;
border-radius: 12px;
background: #f8f7f5;
cursor: pointer;
white-space: nowrap;
font-size: 13px;
transition: all 0.2s;
min-width: 64px;
border: 1.5px solid transparent;
}
.cat-card:hover {
background: #f0eeeb;
}
.cat-card.active {
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
.cat-icon {
font-size: 20px;
}
.cat-label {
font-size: 12px;
}
.cat-arrow {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1.5px solid #d4cfc7;
background: #fff;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #6b6375;
}
.cat-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
background: #f8f7f5;
border-radius: 12px;
padding: 4px 8px;
border: 1.5px solid #e5e4e7;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 10px 8px;
font-size: 14px;
outline: none;
font-family: inherit;
color: #3e3a44;
}
.search-input::placeholder {
color: #b0aab5;
}
.search-btn,
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
padding: 6px 8px;
border-radius: 8px;
color: #6b6375;
}
.search-clear-btn:hover,
.search-btn:hover {
background: #eae8e5;
}
.personal-section {
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #f8f7f5;
border-radius: 10px;
cursor: pointer;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #3e3a44;
}
.section-header:hover {
background: #f0eeeb;
}
.toggle-icon {
font-size: 12px;
color: #999;
}
.section-label {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
padding: 8px 4px;
margin-bottom: 8px;
}
.search-results-section {
margin-bottom: 20px;
}
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.recipe-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,741 @@
<template>
<div class="user-management">
<h3 class="page-title">👥 用户管理</h3>
<!-- Translation Suggestions Review -->
<div v-if="translations.length > 0" class="review-section">
<h4 class="section-title">🌐 翻译建议</h4>
<div class="review-list">
<div v-for="t in translations" :key="t._id || t.id" class="review-item">
<div class="review-info">
<span class="review-original">{{ t.original }}</span>
<span class="review-arrow">&rarr;</span>
<span class="review-suggested">{{ t.suggested }}</span>
<span class="review-user">{{ t.user_name || '匿名' }}</span>
</div>
<div class="review-actions">
<button class="btn-sm btn-approve" @click="approveTranslation(t)">采纳</button>
<button class="btn-sm btn-reject" @click="rejectTranslation(t)">拒绝</button>
</div>
</div>
</div>
</div>
<!-- Business Application Approval -->
<div v-if="businessApps.length > 0" class="review-section">
<h4 class="section-title">💼 商业认证申请</h4>
<div class="review-list">
<div v-for="app in businessApps" :key="app._id || app.id" class="review-item">
<div class="review-info">
<span class="review-name">{{ app.user_name || app.display_name }}</span>
<span class="review-reason">{{ app.reason }}</span>
</div>
<div class="review-actions">
<button class="btn-sm btn-approve" @click="approveBusiness(app)">通过</button>
<button class="btn-sm btn-reject" @click="rejectBusiness(app)">拒绝</button>
</div>
</div>
</div>
</div>
<!-- New User Creation -->
<div class="create-section">
<h4 class="section-title"> 创建新用户</h4>
<div class="create-form">
<input v-model="newUser.username" class="form-input" placeholder="用户名" />
<input v-model="newUser.display_name" class="form-input" placeholder="显示名称" />
<input v-model="newUser.password" class="form-input" type="password" placeholder="密码 (留空自动生成)" />
<select v-model="newUser.role" class="form-select">
<option value="viewer">查看者</option>
<option value="editor">编辑</option>
<option value="senior_editor">高级编辑</option>
<option value="admin">管理员</option>
</select>
<button class="btn-primary" @click="createUser" :disabled="!newUser.username.trim()">创建</button>
</div>
<div v-if="createdLink" class="created-link">
<span>登录链接:</span>
<code>{{ createdLink }}</code>
<button class="btn-sm btn-outline" @click="copyLink(createdLink)">复制</button>
</div>
</div>
<!-- Search & Filter -->
<div class="filter-toolbar">
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索用户..."
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<div class="role-filters">
<button
v-for="r in roles"
:key="r.value"
class="filter-btn"
:class="{ active: filterRole === r.value }"
@click="filterRole = filterRole === r.value ? '' : r.value"
>{{ r.label }}</button>
</div>
</div>
<!-- User List -->
<div class="user-list">
<div v-for="u in filteredUsers" :key="u._id || u.id" class="user-card">
<div class="user-info">
<div class="user-name">
{{ u.display_name || u.username }}
<span class="user-username" v-if="u.display_name">@{{ u.username }}</span>
</div>
<div class="user-meta">
<span class="user-role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
<span v-if="u.business_verified" class="biz-badge">💼 商业认证</span>
<span class="user-date">注册: {{ formatDate(u.created_at) }}</span>
</div>
</div>
<div class="user-actions">
<select
:value="u.role"
class="role-select"
@change="changeRole(u, $event.target.value)"
>
<option value="viewer">查看者</option>
<option value="editor">编辑</option>
<option value="senior_editor">高级编辑</option>
<option value="admin">管理员</option>
</select>
<button class="btn-sm btn-outline" @click="copyUserLink(u)" title="复制登录链接">🔗</button>
<button class="btn-sm btn-delete" @click="removeUser(u)" title="删除用户">🗑</button>
</div>
</div>
<div v-if="filteredUsers.length === 0" class="empty-hint">未找到用户</div>
</div>
<div class="user-count"> {{ users.length }} 个用户</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
const users = ref([])
const searchQuery = ref('')
const filterRole = ref('')
const translations = ref([])
const businessApps = ref([])
const createdLink = ref('')
const newUser = reactive({
username: '',
display_name: '',
password: '',
role: 'viewer',
})
const roles = [
{ value: 'admin', label: '管理员' },
{ value: 'senior_editor', label: '高级编辑' },
{ value: 'editor', label: '编辑' },
{ value: 'viewer', label: '查看者' },
]
const filteredUsers = computed(() => {
let list = users.value
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase()
list = list.filter(u =>
(u.username || '').toLowerCase().includes(q) ||
(u.display_name || '').toLowerCase().includes(q)
)
}
if (filterRole.value) {
list = list.filter(u => u.role === filterRole.value)
}
return list
})
function roleLabel(role) {
const map = { admin: '管理员', senior_editor: '高级编辑', editor: '编辑', viewer: '查看者' }
return map[role] || role
}
function formatDate(d) {
if (!d) return '--'
return new Date(d).toLocaleDateString('zh-CN')
}
async function loadUsers() {
try {
const res = await api('/api/users')
if (res.ok) {
users.value = await res.json()
}
} catch {
users.value = []
}
}
async function loadTranslations() {
try {
const res = await api('/api/translation-suggestions')
if (res.ok) {
translations.value = await res.json()
}
} catch {
translations.value = []
}
}
async function loadBusinessApps() {
try {
const res = await api('/api/business-applications')
if (res.ok) {
businessApps.value = await res.json()
}
} catch {
businessApps.value = []
}
}
async function createUser() {
if (!newUser.username.trim()) return
try {
const res = await api('/api/users', {
method: 'POST',
body: JSON.stringify({
username: newUser.username.trim(),
display_name: newUser.display_name.trim() || newUser.username.trim(),
password: newUser.password || undefined,
role: newUser.role,
}),
})
if (res.ok) {
const data = await res.json()
if (data.token) {
const baseUrl = window.location.origin
createdLink.value = `${baseUrl}/?token=${data.token}`
}
newUser.username = ''
newUser.display_name = ''
newUser.password = ''
newUser.role = 'viewer'
await loadUsers()
ui.showToast('用户已创建')
} else {
const err = await res.json().catch(() => ({}))
ui.showToast('创建失败: ' + (err.error || err.message || ''))
}
} catch {
ui.showToast('创建失败')
}
}
async function changeRole(user, newRole) {
const id = user._id || user.id
try {
const res = await api(`/api/users/${id}/role`, {
method: 'PUT',
body: JSON.stringify({ role: newRole }),
})
if (res.ok) {
user.role = newRole
ui.showToast(`已更新 ${user.display_name || user.username} 的角色`)
}
} catch {
ui.showToast('更新失败')
}
}
async function removeUser(user) {
const ok = await showConfirm(`确定删除用户 "${user.display_name || user.username}"?此操作不可撤销。`)
if (!ok) return
const id = user._id || user.id
try {
const res = await api(`/api/users/${id}`, { method: 'DELETE' })
if (res.ok) {
users.value = users.value.filter(u => (u._id || u.id) !== id)
ui.showToast('已删除')
}
} catch {
ui.showToast('删除失败')
}
}
async function copyUserLink(user) {
try {
const id = user._id || user.id
const res = await api(`/api/users/${id}/token`)
if (res.ok) {
const data = await res.json()
const link = `${window.location.origin}/?token=${data.token}`
await navigator.clipboard.writeText(link)
ui.showToast('链接已复制')
}
} catch {
ui.showToast('获取链接失败')
}
}
async function copyLink(link) {
try {
await navigator.clipboard.writeText(link)
ui.showToast('已复制')
} catch {
ui.showToast('复制失败')
}
}
async function approveTranslation(t) {
const id = t._id || t.id
try {
const res = await api(`/api/translation-suggestions/${id}/approve`, { method: 'POST' })
if (res.ok) {
translations.value = translations.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已采纳')
}
} catch {
ui.showToast('操作失败')
}
}
async function rejectTranslation(t) {
const id = t._id || t.id
try {
const res = await api(`/api/translation-suggestions/${id}/reject`, { method: 'POST' })
if (res.ok) {
translations.value = translations.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已拒绝')
}
} catch {
ui.showToast('操作失败')
}
}
async function approveBusiness(app) {
const id = app._id || app.id
try {
const res = await api(`/api/business-applications/${id}/approve`, { method: 'POST' })
if (res.ok) {
businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已通过')
}
} catch {
ui.showToast('操作失败')
}
}
async function rejectBusiness(app) {
const id = app._id || app.id
try {
const res = await api(`/api/business-applications/${id}/reject`, { method: 'POST' })
if (res.ok) {
businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已拒绝')
}
} catch {
ui.showToast('操作失败')
}
}
onMounted(() => {
loadUsers()
loadTranslations()
loadBusinessApps()
})
</script>
<style scoped>
.user-management {
padding: 0 12px 24px;
}
.page-title {
margin: 0 0 16px;
font-size: 16px;
color: #3e3a44;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin: 0 0 10px;
}
/* Review sections */
.review-section {
margin-bottom: 18px;
padding: 14px;
background: #fff8e1;
border-radius: 12px;
border: 1.5px solid #ffe082;
}
.review-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.review-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #fff;
border-radius: 8px;
gap: 8px;
flex-wrap: wrap;
}
.review-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
font-size: 13px;
}
.review-original {
font-weight: 500;
color: #3e3a44;
}
.review-arrow {
color: #b0aab5;
}
.review-suggested {
font-weight: 600;
color: #4a9d7e;
}
.review-user,
.review-reason {
font-size: 12px;
color: #999;
}
.review-name {
font-weight: 600;
color: #3e3a44;
}
.review-actions {
display: flex;
gap: 6px;
}
.btn-approve {
background: #4a9d7e;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
border-radius: 8px;
}
.btn-reject {
background: #ef5350;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
border-radius: 8px;
}
/* Create user */
.create-section {
margin-bottom: 18px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.create-form {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.form-input {
flex: 1;
min-width: 120px;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
.form-select {
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.created-link {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 10px 12px;
background: #e8f5e9;
border-radius: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.created-link code {
flex: 1;
word-break: break-all;
font-size: 11px;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
}
/* Filter toolbar */
.filter-toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
align-items: center;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
flex: 1;
min-width: 150px;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.role-filters {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.filter-btn {
padding: 5px 14px;
border-radius: 16px;
border: 1.5px solid #e5e4e7;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
transition: all 0.15s;
}
.filter-btn.active {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
/* User list */
.user-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
gap: 10px;
flex-wrap: wrap;
}
.user-card:hover {
border-color: #d4cfc7;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 4px;
}
.user-username {
font-weight: 400;
font-size: 12px;
color: #b0aab5;
margin-left: 4px;
}
.user-meta {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.user-role-badge {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.role-admin { background: #f3e5f5; color: #7b1fa2; }
.role-senior_editor { background: #e3f2fd; color: #1565c0; }
.role-editor { background: #e8f5e9; color: #2e7d5a; }
.role-viewer { background: #f5f5f5; color: #757575; }
.biz-badge {
font-size: 11px;
color: #e65100;
}
.user-date {
font-size: 11px;
color: #b0aab5;
}
.user-actions {
display: flex;
gap: 6px;
align-items: center;
}
.role-select {
padding: 5px 8px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 12px;
font-family: inherit;
background: #fff;
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
border-radius: 8px;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-delete {
background: #fff;
color: #ef5350;
border: 1.5px solid #ffcdd2;
cursor: pointer;
font-family: inherit;
}
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.user-count {
text-align: center;
font-size: 12px;
color: #b0aab5;
margin-top: 16px;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.create-form {
flex-direction: column;
}
.create-form .form-input,
.create-form .form-select {
width: 100%;
}
.user-card {
flex-direction: column;
align-items: flex-start;
}
.user-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:8000',
'/uploads': 'http://localhost:8000'
}
},
build: {
outDir: 'dist'
}
})