Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E
- Replace single-file 8441-line HTML with Vue 3 SPA - Pinia stores: auth, oils, recipes, diary, ui - Composables: useApi, useDialog, useSmartPaste, useOilTranslation - 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc. - 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc. - 14 Cypress E2E test specs (113 tests), all passing - Multi-stage Dockerfile (Node build + Python runtime) - Demo video generation scripts (TTS + subtitles + screen recording) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
380
frontend/src/views/AuditLog.vue
Normal file
380
frontend/src/views/AuditLog.vue
Normal 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>
|
||||
644
frontend/src/views/BugTracker.vue
Normal file
644
frontend/src/views/BugTracker.vue
Normal 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>
|
||||
383
frontend/src/views/Inventory.vue
Normal file
383
frontend/src/views/Inventory.vue
Normal 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>
|
||||
872
frontend/src/views/MyDiary.vue
Normal file
872
frontend/src/views/MyDiary.vue
Normal 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="粘贴配方文本,智能识别... 例如: 舒缓配方,薰衣草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>
|
||||
788
frontend/src/views/OilReference.vue
Normal file
788
frontend/src/views/OilReference.vue
Normal 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>婴儿(<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>
|
||||
759
frontend/src/views/Projects.vue
Normal file
759
frontend/src/views/Projects.vue
Normal 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">← 返回列表</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>
|
||||
967
frontend/src/views/RecipeManager.vue
Normal file
967
frontend/src/views/RecipeManager.vue
Normal 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="粘贴配方文本,支持智能识别... 例如: 薰衣草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>
|
||||
398
frontend/src/views/RecipeSearch.vue
Normal file
398
frontend/src/views/RecipeSearch.vue
Normal 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">‹</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)">›</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>
|
||||
741
frontend/src/views/UserManagement.vue
Normal file
741
frontend/src/views/UserManagement.vue
Normal 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">→</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>
|
||||
Reference in New Issue
Block a user