Compare commits
7 Commits
feat/inven
...
feat/oil-p
| Author | SHA1 | Date | |
|---|---|---|---|
| de89ccebac | |||
| e605da786a | |||
| 87e24773aa | |||
| 026ff18e92 | |||
| 6448c24caf | |||
| 5cd954ccad | |||
| c53dda0622 |
@@ -12,6 +12,7 @@ jobs:
|
||||
e2e-test:
|
||||
runs-on: test
|
||||
needs: unit-test
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -23,26 +24,43 @@ jobs:
|
||||
|
||||
- name: E2E tests
|
||||
run: |
|
||||
# Dynamic ports to avoid conflicts
|
||||
BE_PORT=$(shuf -i 9000-9999 -n 1)
|
||||
FE_PORT=$(shuf -i 4000-4999 -n 1)
|
||||
DB_FILE="/tmp/ci_oil_test_${BE_PORT}.db"
|
||||
echo "Using backend=$BE_PORT frontend=$FE_PORT db=$DB_FILE"
|
||||
|
||||
# Start backend
|
||||
DB_PATH=/tmp/ci_oil_test.db FRONTEND_DIR=/dev/null \
|
||||
/tmp/ci-venv/bin/uvicorn backend.main:app --port 8000 &
|
||||
DB_PATH="$DB_FILE" FRONTEND_DIR=/dev/null \
|
||||
/tmp/ci-venv/bin/uvicorn backend.main:app --port $BE_PORT &
|
||||
BE_PID=$!
|
||||
|
||||
# Start frontend (in subshell to not change cwd)
|
||||
(cd frontend && npx vite --port 5173) &
|
||||
# Start frontend with proxy to dynamic backend port
|
||||
(cd frontend && VITE_API_PORT=$BE_PORT npx vite --port $FE_PORT) &
|
||||
FE_PID=$!
|
||||
|
||||
# Wait for both servers
|
||||
# Wait for both servers (max 30s, fail fast)
|
||||
READY=0
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:8000/api/version > /dev/null 2>&1 && \
|
||||
curl -sf http://localhost:5173/ > /dev/null 2>&1; then
|
||||
echo "Both servers ready"
|
||||
if curl -sf http://localhost:$BE_PORT/api/oils > /dev/null 2>&1 && \
|
||||
curl -sf http://localhost:$FE_PORT/ > /dev/null 2>&1; then
|
||||
echo "Both servers ready in ${i}s"
|
||||
READY=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run core cypress specs (proven stable)
|
||||
if [ "$READY" = "0" ]; then
|
||||
echo "ERROR: Servers failed to start within 30s"
|
||||
kill $BE_PID $FE_PID 2>/dev/null
|
||||
rm -f "$DB_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run core cypress specs with hard 3-minute timeout
|
||||
cd frontend
|
||||
npx cypress run --spec "\
|
||||
timeout 180 npx cypress run --spec "\
|
||||
cypress/e2e/recipe-detail.cy.js,\
|
||||
cypress/e2e/oil-reference.cy.js,\
|
||||
cypress/e2e/oil-data-integrity.cy.js,\
|
||||
@@ -50,13 +68,17 @@ jobs:
|
||||
cypress/e2e/category-modules.cy.js,\
|
||||
cypress/e2e/notification-flow.cy.js,\
|
||||
cypress/e2e/registration-flow.cy.js\
|
||||
" --config video=false
|
||||
" --config "video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT"
|
||||
EXIT_CODE=$?
|
||||
|
||||
# Cleanup
|
||||
pkill -f "uvicorn backend" || true
|
||||
pkill -f "node.*vite" || true
|
||||
rm -f /tmp/ci_oil_test.db
|
||||
kill $BE_PID $FE_PID 2>/dev/null
|
||||
pkill -f "Cypress" 2>/dev/null || true
|
||||
rm -f "$DB_FILE"
|
||||
if [ $EXIT_CODE -eq 124 ]; then
|
||||
echo "ERROR: Cypress timed out after 3 minutes"
|
||||
exit 1
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
|
||||
build-check:
|
||||
|
||||
@@ -355,6 +355,8 @@ def register(body: dict):
|
||||
"INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)",
|
||||
(username, token, "viewer", display_name or username, hash_password(password))
|
||||
)
|
||||
uid = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
|
||||
log_audit(conn, uid["id"] if uid else None, "register", "user", username, display_name or username, None)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.close()
|
||||
|
||||
@@ -85,3 +85,51 @@ describe('EDITOR_ONLY_TAGS', () => {
|
||||
expect(EDITOR_ONLY_TAGS).toContain('已审核')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// English search
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('English search matching', () => {
|
||||
const { oilEn } = require('../composables/useOilTranslation')
|
||||
|
||||
it('oilEn returns English name for known oils', () => {
|
||||
expect(oilEn('薰衣草')).toBe('Lavender')
|
||||
expect(oilEn('茶树')).toBe('Tea Tree')
|
||||
expect(oilEn('乳香')).toBe('Frankincense')
|
||||
})
|
||||
|
||||
it('oilEn returns empty for unknown oils', () => {
|
||||
expect(oilEn('不存在的油')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('English query detection', () => {
|
||||
const isEn = (q) => /^[a-zA-Z\s]+$/.test(q)
|
||||
expect(isEn('lavender')).toBe(true)
|
||||
expect(isEn('Tea Tree')).toBe(true)
|
||||
expect(isEn('薰衣草')).toBe(false)
|
||||
expect(isEn('lav3')).toBe(false)
|
||||
})
|
||||
|
||||
it('English matches oil name in recipe', () => {
|
||||
const recipe = {
|
||||
name: '助眠配方',
|
||||
en_name: 'Sleep Aid Blend',
|
||||
ingredients: [{ oil: '薰衣草', drops: 3 }],
|
||||
tags: []
|
||||
}
|
||||
const q = 'lavender'
|
||||
const isEn = /^[a-zA-Z\s]+$/.test(q)
|
||||
const enNameMatch = isEn && (recipe.en_name || '').toLowerCase().includes(q)
|
||||
const oilEnMatch = isEn && recipe.ingredients.some(ing => (oilEn(ing.oil) || '').toLowerCase().includes(q))
|
||||
expect(oilEnMatch).toBe(true)
|
||||
expect(enNameMatch).toBe(false)
|
||||
})
|
||||
|
||||
it('English matches recipe en_name', () => {
|
||||
const recipe = { name: '助眠', en_name: 'Sleep Aid Blend', ingredients: [], tags: [] }
|
||||
const q = 'sleep'
|
||||
const isEn = /^[a-zA-Z\s]+$/.test(q)
|
||||
const enNameMatch = isEn && (recipe.en_name || '').toLowerCase().includes(q)
|
||||
expect(enNameMatch).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<span v-for="tag in visibleTags" :key="tag" class="tag" :class="{ 'tag-reviewed': tag === '已审核' }">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="recipe-card-oils">{{ oilNames }}</div>
|
||||
<span v-if="volumeLabel" class="recipe-card-volume">{{ volumeLabel }}</span>
|
||||
<div class="recipe-card-bottom">
|
||||
<div class="recipe-card-price">💰 {{ priceInfo.cost }}</div>
|
||||
<button
|
||||
@@ -41,10 +42,20 @@ const visibleTags = computed(() => {
|
||||
})
|
||||
|
||||
const oilNames = computed(() =>
|
||||
props.recipe.ingredients.map(i => i.oil).join('、')
|
||||
[...props.recipe.ingredients].sort((a, b) => a.oil.localeCompare(b.oil, 'zh')).map(i => i.oil).join('、')
|
||||
)
|
||||
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(props.recipe.ingredients))
|
||||
const isFav = computed(() => recipesStore.isFavorite(props.recipe))
|
||||
|
||||
const volumeLabel = computed(() => {
|
||||
const ings = props.recipe.ingredients || []
|
||||
const coco = ings.find(i => i.oil === '椰子油')
|
||||
if (!coco || !coco.drops) return ''
|
||||
const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0)
|
||||
const ml = totalDrops / 18.6
|
||||
if (ml <= 2) return '单次'
|
||||
return `${Math.round(ml)}ml`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -98,6 +109,13 @@ const isFav = computed(() => recipesStore.isFavorite(props.recipe))
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.recipe-card-volume {
|
||||
font-size: 10px;
|
||||
color: #b0aab5;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.recipe-card-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -96,6 +96,7 @@ const ACTION_MAP = {
|
||||
reject_business: '拒绝商业认证',
|
||||
grant_business: '开通商业认证',
|
||||
revoke_business: '撤销商业认证',
|
||||
register: '用户注册',
|
||||
}
|
||||
|
||||
const actionTypes = [
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Recipe Overlay -->
|
||||
<div v-if="showAddOverlay" class="overlay" @click.self="closeOverlay">
|
||||
<div v-if="showAddOverlay" class="overlay">
|
||||
<div class="overlay-panel">
|
||||
<div class="overlay-header">
|
||||
<h3>{{ editingRecipe ? '编辑配方' : '添加配方' }}</h3>
|
||||
@@ -1312,12 +1312,10 @@ function diaryMatchesPublic(d) {
|
||||
}
|
||||
|
||||
function getDiaryShareStatus(d) {
|
||||
// Check pending (owned by user in public library, not yet adopted)
|
||||
if (sharedCount.value.pendingNames.includes(d.name)) return 'pending'
|
||||
// Check if public library has same recipe with same content
|
||||
// Admin/senior_editor share directly — check public match first
|
||||
if (diaryMatchesPublic(d)) return 'shared'
|
||||
// Check adopted names from audit log
|
||||
if (sharedCount.value.adoptedNames.includes(d.name) && diaryMatchesPublic(d)) return 'shared'
|
||||
// Non-admin: check pending (owned by user, not yet adopted)
|
||||
if (!auth.isAdmin && !auth.canManage && sharedCount.value.pendingNames.includes(d.name)) return 'pending'
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ import { useUiStore } from '../stores/ui'
|
||||
import { api } from '../composables/useApi'
|
||||
import RecipeCard from '../components/RecipeCard.vue'
|
||||
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
|
||||
import { oilEn } from '../composables/useOilTranslation'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
@@ -313,11 +314,14 @@ function expandQuery(q) {
|
||||
const exactResults = computed(() => {
|
||||
if (!searchQuery.value.trim()) return []
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const isEn = /^[a-zA-Z\s]+$/.test(q)
|
||||
return recipeStore.recipes.filter(r => {
|
||||
const nameMatch = r.name.toLowerCase().includes(q)
|
||||
const enNameMatch = isEn && (r.en_name || '').toLowerCase().includes(q)
|
||||
const oilEnMatch = isEn && r.ingredients.some(ing => (oilEn(ing.oil) || '').toLowerCase().includes(q))
|
||||
const visibleTags = auth.canEdit ? (r.tags || []) : (r.tags || []).filter(t => !EDITOR_ONLY_TAGS.includes(t))
|
||||
const tagMatch = visibleTags.some(t => t.toLowerCase().includes(q))
|
||||
return nameMatch || tagMatch
|
||||
return nameMatch || enNameMatch || oilEnMatch || tagMatch
|
||||
}).sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<span class="review-name">{{ group.latest.display_name || group.latest.username }}</span>
|
||||
<span class="review-reason">商户名:{{ group.latest.business_name }}</span>
|
||||
<span class="biz-status-tag" :class="'biz-' + group.effectiveStatus">{{ { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[group.effectiveStatus] }}</span>
|
||||
<img v-if="group.latest.document && group.latest.document.startsWith('data:image')" :src="group.latest.document" class="biz-doc-preview" @click="showDocFull = group.latest.document" />
|
||||
</div>
|
||||
<div class="review-actions">
|
||||
<template v-if="group.effectiveStatus === 'pending'">
|
||||
@@ -111,6 +112,11 @@
|
||||
</div>
|
||||
|
||||
<div class="user-count">共 {{ users.length }} 个用户</div>
|
||||
|
||||
<!-- Full-size document preview -->
|
||||
<div v-if="showDocFull" class="doc-overlay" @click="showDocFull = null">
|
||||
<img :src="showDocFull" class="doc-full-img" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -128,6 +134,7 @@ const users = ref([])
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('')
|
||||
const translations = ref([])
|
||||
const showDocFull = ref(null)
|
||||
const businessApps = ref([])
|
||||
import { reactive } from 'vue'
|
||||
|
||||
@@ -443,6 +450,10 @@ onMounted(() => {
|
||||
}
|
||||
.biz-reject-reason { color: #c62828; font-size: 11px; }
|
||||
.biz-time { color: #bbb; font-size: 11px; margin-left: auto; }
|
||||
.biz-doc-preview { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid #e5e4e7; margin-top: 6px; }
|
||||
.biz-doc-preview:hover { border-color: #7ec6a4; }
|
||||
.doc-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.doc-full-img { max-width: 90vw; max-height: 90vh; border-radius: 10px; }
|
||||
|
||||
.btn-approve {
|
||||
background: #4a9d7e;
|
||||
|
||||
@@ -10,8 +10,8 @@ export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/uploads': 'http://localhost:8000'
|
||||
'/api': `http://localhost:${process.env.VITE_API_PORT || 8000}`,
|
||||
'/uploads': `http://localhost:${process.env.VITE_API_PORT || 8000}`
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user