feat: 商业认证+核算页面重写,管理入口移到用户菜单
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Successful in 55s

商业认证:
- 重写申请表单:认证类型、企业名称、联系电话、业务描述
- 状态栏样式:左侧彩色条(绿/橙/红)
- 用户管理页:同一用户只显示一条,可展开历史查看拒绝原因
- 后端 API 补充 reject_reason 字段

商业核算:
- 成分表改为标准表格(精油/用量/单价/小计)
- 总成本显示栏(绿色背景大字)
- 定价字段放在成本下方

管理入口:
- 操作日志/Bug/用户管理从主 tab 栏移到管理员用户菜单
- 添加配方按钮对所有用户可见

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:01:36 +00:00
parent ad95ba7d1f
commit 418986e46c
7 changed files with 223 additions and 102 deletions

View File

@@ -501,7 +501,7 @@ def get_my_business_application(user=Depends(get_current_user)):
def list_business_applications(user=Depends(require_role("admin"))):
conn = get_db()
rows = conn.execute(
"SELECT a.id, a.user_id, a.business_name, a.document, a.status, a.created_at, "
"SELECT a.id, a.user_id, a.business_name, a.document, a.status, a.reject_reason, a.created_at, "
"u.display_name, u.username FROM business_applications a "
"LEFT JOIN users u ON a.user_id = u.id ORDER BY a.id DESC"
).fetchall()

View File

@@ -82,9 +82,6 @@ const allTabs = [
{ key: 'inventory', icon: '📦', label: '个人库存', require: 'login' },
{ key: 'oils', icon: '💧', label: '精油价目' },
{ key: 'projects', icon: '💼', label: '商业核算', require: 'login' },
{ key: 'audit', icon: '📜', label: '操作日志', hide: 'admin' },
{ key: 'bugs', icon: '🐛', label: 'Bug', hide: 'admin' },
{ key: 'users', icon: '👥', label: '用户管理', hide: 'admin' },
]
// 所有人都能看到大部分 tabbug 和用户管理只有 admin 可见

View File

@@ -14,6 +14,11 @@
<button class="usermenu-btn" @click="showBugReport">
🐛 反馈问题
</button>
<template v-if="auth.isAdmin">
<button class="usermenu-btn" @click="goAdmin('audit')">📜 操作日志</button>
<button class="usermenu-btn" @click="goAdmin('bugs')">🐛 Bug管理</button>
<button class="usermenu-btn" @click="goAdmin('users')">👥 用户管理</button>
</template>
<button class="usermenu-btn usermenu-btn-logout" @click="handleLogout">
🚪 退出登录
</button>
@@ -88,6 +93,11 @@ function goMyDiary() {
router.push('/mydiary')
}
function goAdmin(section) {
emit('close')
router.push('/' + section)
}
function toggleNotifications() {
showNotifPanel.value = !showNotifPanel.value
showBugForm.value = false

View File

@@ -241,56 +241,58 @@
</div>
<!-- Business Verification -->
<div ref="bizCertRef" class="section-card">
<h4>💼 商业认证</h4>
<div ref="bizCertRef" class="section-card biz-card">
<h4>🏢 商业用户认证</h4>
<!-- 已认证 -->
<div v-if="auth.isBusiness" class="biz-status biz-approved">
<div class="biz-status-icon"></div>
<div class="biz-status-text">已认证商业用户</div>
<div v-if="auth.isBusiness" class="biz-status-bar biz-approved">
<span> 已认证商业用户</span>
</div>
<!-- 审核中 -->
<template v-else-if="bizApp.status === 'pending'">
<div class="biz-status biz-pending">
<div class="biz-status-icon"></div>
<div class="biz-status-text">认证申请审核中</div>
<div class="biz-status-detail">商户名{{ bizApp.business_name }}</div>
<div class="biz-status-detail">提交时间{{ formatDate(bizApp.created_at) }}</div>
<div v-else-if="bizApp.status === 'pending'" class="biz-status-bar biz-pending">
<span> 认证申请审核中</span>
<div class="biz-status-detail">商户名{{ bizApp.business_name }} · 提交时间{{ formatDate(bizApp.created_at) }}</div>
</div>
</template>
<!-- 被拒绝可重新申请 -->
<!-- 被拒绝 -->
<template v-else-if="bizApp.status === 'rejected'">
<div class="biz-status biz-rejected">
<div class="biz-status-icon"></div>
<div class="biz-status-text">认证申请未通过</div>
<div v-if="bizApp.reject_reason" class="biz-reject-reason">原因{{ bizApp.reject_reason }}</div>
<div class="biz-status-bar biz-rejected">
<span> 认证申请未通过</span>
<div v-if="bizApp.reject_reason" class="biz-status-detail">原因{{ bizApp.reject_reason }}</div>
</div>
<p class="hint-text">你可以修改信息后重新申请</p>
<div class="form-group">
<label>商户名称</label>
<input v-model="businessName" class="form-input" placeholder="你的商户/品牌名称" />
</div>
<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="!businessName.trim()">重新申请</button>
</template>
<!-- 首次申请 -->
<template v-else>
<p class="hint-text">申请商业认证后可使用商业核算功能请填写以下信息</p>
<!-- 申请表单首次或被拒后重新申请 -->
<template v-if="!auth.isBusiness && bizApp.status !== 'pending'">
<div class="biz-form">
<div class="form-group">
<label>商户名称</label>
<input v-model="businessName" class="form-input" placeholder="你的商户/品牌名称" />
<label class="form-label">认证类型 *</label>
<select v-model="bizType" class="form-select">
<option value="">请选择</option>
<option value="individual">个体经营户</option>
<option value="company">公司</option>
<option value="studio">工作室/美容院</option>
<option value="distributor">代理商</option>
</select>
</div>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的业务情况和申请理由..."></textarea>
<label class="form-label">企业/商户名称 *</label>
<input v-model="businessName" class="form-input" placeholder="你的企业或品牌名称" />
</div>
<div class="form-group">
<label class="form-label">联系电话 *</label>
<input v-model="bizPhone" class="form-input" type="tel" placeholder="联系电话" />
</div>
<div class="form-group">
<label class="form-label">业务描述</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="描述你的业务范围和计划..."></textarea>
</div>
<div style="display:flex;gap:10px;margin-top:12px">
<button class="btn-primary" @click="applyBusiness" :disabled="!businessName.trim() || !bizType">💾 提交申请</button>
</div>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessName.trim()">提交申请</button>
</template>
</div>
</div>
@@ -341,6 +343,8 @@ const newPassword = ref('')
const confirmPassword = ref('')
const businessName = ref('')
const businessReason = ref('')
const bizType = ref('')
const bizPhone = ref('')
const bizApp = ref({ status: null })
onMounted(async () => {
@@ -669,24 +673,30 @@ async function changePassword() {
}
async function applyBusiness() {
if (!businessName.value.trim()) {
ui.showToast('请填写商户名称')
if (!businessName.value.trim() || !bizType.value) {
ui.showToast('请填写必填项')
return
}
const typeLabels = { individual: '个体经营户', company: '公司', studio: '工作室/美容院', distributor: '代理商' }
const info = [
`认证类型:${typeLabels[bizType.value] || bizType.value}`,
bizPhone.value ? `联系电话:${bizPhone.value}` : '',
businessReason.value ? `业务描述:${businessReason.value}` : '',
].filter(Boolean).join('\n')
try {
const res = await api('/api/business-apply', {
method: 'POST',
body: JSON.stringify({
business_name: businessName.value.trim(),
document: businessReason.value.trim(),
document: info,
}),
})
if (res.ok) {
businessName.value = ''
businessReason.value = ''
bizApp.value = { status: 'pending', business_name: businessName.value }
bizType.value = ''
bizPhone.value = ''
ui.showToast('申请已提交,请等待管理员审核')
// Reload status
const bizRes = await api('/api/my-business-application')
if (bizRes.ok) bizApp.value = await bizRes.json()
} else {
@@ -1147,19 +1157,27 @@ async function applyBusiness() {
text-align: center;
}
.biz-status {
padding: 16px;
border-radius: 12px;
text-align: center;
.biz-card { border-radius: 16px; }
.biz-status-bar {
padding: 12px 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
line-height: 1.6;
}
.biz-status-icon { font-size: 32px; margin-bottom: 8px; }
.biz-status-text { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.biz-status-detail { font-size: 12px; color: #999; }
.biz-approved { background: #e8f5e9; color: #2e7d5a; }
.biz-pending { background: #fff8e1; color: #e65100; }
.biz-rejected { background: #fce4ec; color: #c62828; }
.biz-reject-reason { font-size: 13px; margin-top: 8px; padding: 8px 12px; background: rgba(0,0,0,0.05); border-radius: 8px; }
.biz-status-bar.biz-approved { background: #e8f5e9; color: #2e7d32; border-left: 3px solid #4caf50; }
.biz-status-bar.biz-pending { background: #fff3e0; color: #e65100; border-left: 3px solid #ff9800; }
.biz-status-bar.biz-rejected { background: #ffebee; color: #c62828; border-left: 3px solid #f44336; }
.biz-status-detail { font-size: 12px; margin-top: 4px; opacity: 0.8; }
.biz-form { margin-top: 8px; }
.biz-form .form-group { margin-bottom: 14px; }
.biz-form .form-label { display: block; font-size: 13px; font-weight: 600; color: #3e3a44; margin-bottom: 6px; }
.biz-form .form-select {
width: 100%; padding: 10px 14px; border: 1.5px solid #d4cfc7; border-radius: 10px;
font-size: 14px; font-family: inherit; background: #fff; outline: none; box-sizing: border-box;
}
.biz-form .form-select:focus { border-color: #7ec6a4; }
/* Buttons */
.btn-primary {

View File

@@ -42,26 +42,53 @@
<button class="btn-outline btn-sm" @click="importFromRecipe">📋 从配方导入</button>
</div>
<!-- Ingredients Editor -->
<!-- Ingredients Table -->
<div class="ingredients-section">
<div class="section-header-row">
<h4>🧴 配方成分</h4>
<div v-for="(ing, i) in selectedProject.ingredients" :key="i" class="ing-row">
<div class="section-actions">
<button class="btn-outline btn-sm" @click="addIngredient"> 添加精油</button>
</div>
</div>
<table class="ingredients-table">
<thead>
<tr>
<th>精油</th>
<th>单次用量()</th>
<th>单价/</th>
<th>小计</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(ing, i) in selectedProject.ingredients" :key="i">
<td>
<select v-model="ing.oil" class="form-select" @change="saveProject">
<option value="">选择精油</option>
<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>
</td>
<td>
<input v-model.number="ing.drops" type="number" min="0" step="0.5" class="drops-input" @change="saveProject" />
</td>
<td class="cell-ppd">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '—' }}</td>
<td class="cell-subtotal">{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '—' }}</td>
<td><button class="remove-btn" @click="removeIngredient(i)">×</button></td>
</tr>
</tbody>
</table>
<div class="total-row">
<span class="total-label">配方总成本</span>
<span class="total-price">{{ oils.fmtPrice(materialCost) }}</span>
</div>
<!-- Pricing -->
<div class="pricing-inline">
<div class="price-field">
<label>定价 ¥</label>
<input v-model.number="selectedProject.selling_price" type="number" class="price-input" placeholder="/次" @change="saveProject" />
</div>
</div>
<button class="btn-outline btn-sm" @click="addIngredient">+ 添加成分</button>
</div>
<!-- Pricing Section -->
@@ -492,12 +519,38 @@ function formatDate(d) {
color: #3e3a44;
}
.ing-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
.section-header-row {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;
}
.section-header-row h4 { margin: 0; }
.section-actions { display: flex; gap: 6px; }
.ingredients-table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.ingredients-table th {
text-align: center; padding: 10px 8px; font-size: 12px; font-weight: 600;
color: var(--text-light, #999); border-bottom: 2px solid #e5e4e7;
}
.ingredients-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
.ingredients-table .form-select { width: 100%; padding: 6px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; font-family: inherit; background: #fff; }
.drops-input { width: 65px; padding: 6px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; }
.drops-input:focus { border-color: #7ec6a4; }
.cell-ppd { color: #999; font-size: 12px; }
.cell-subtotal { color: #4a9d7e; font-weight: 600; }
.remove-btn { border: none; background: none; color: #ccc; cursor: pointer; font-size: 18px; }
.remove-btn:hover { color: #c0392b; }
.total-row {
background: #e8f5e9; border-radius: 12px; padding: 14px 18px;
display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
}
.total-label { font-size: 14px; color: #3e3a44; font-weight: 500; }
.total-price { font-size: 20px; font-weight: 700; color: #2e7d5a; }
.pricing-inline { margin-top: 12px; }
.price-field { display: flex; align-items: center; gap: 8px; }
.price-field label { font-size: 13px; font-weight: 600; color: #3e3a44; white-space: nowrap; }
.price-input { width: 100px; padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 14px; font-family: inherit; outline: none; }
.price-input:focus { border-color: #7ec6a4; }
.form-select {
flex: 1;
@@ -520,14 +573,6 @@ function formatDate(d) {
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;

View File

@@ -26,7 +26,6 @@
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''"></button>
</div>
<div class="toolbar-actions">
<button class="btn-outline btn-sm" @click="showAddOverlay = true">+ 添加配方</button>
<button class="btn-outline btn-sm" @click="exportExcel">📥 导出Excel</button>
</div>
</div>
@@ -40,6 +39,7 @@
:class="selectedDiaryIds.size > 0 ? 'btn-select-active' : 'btn-outline'"
@click="toggleSelectAllDiary"
>全选</button>
<button class="btn-outline btn-sm" @click="showAddOverlay = true">+ 添加配方</button>
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
🏷 标签筛选 {{ showTagFilter ? '' : '' }}
</button>

View File

@@ -22,17 +22,33 @@
</div>
<!-- Business Application Approval -->
<div v-if="businessApps.length > 0" class="review-section">
<div v-if="groupedBizApps.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 v-for="group in groupedBizApps" :key="group.user_id" class="biz-app-group">
<div class="review-item">
<div class="review-info">
<span class="review-name">{{ app.display_name || app.username }}</span>
<span class="review-reason">商户名{{ app.business_name }}</span>
<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.latest.status">{{ { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[group.latest.status] }}</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>
<template v-if="group.latest.status === 'pending'">
<button class="btn-sm btn-approve" @click="approveBusiness(group.latest)">通过</button>
<button class="btn-sm btn-reject" @click="rejectBusiness(group.latest)">拒绝</button>
</template>
<button v-if="group.history.length > 1" class="btn-sm btn-outline" @click="group.expanded = !group.expanded">
{{ group.expanded ? '收起' : `历史 (${group.history.length})` }}
</button>
</div>
</div>
<div v-if="group.expanded" class="biz-history">
<div v-for="app in group.history" :key="app.id" class="biz-history-item">
<span class="biz-status-tag small" :class="'biz-' + app.status">{{ { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[app.status] }}</span>
<span>{{ app.business_name }}</span>
<span v-if="app.reject_reason" class="biz-reject-reason">拒绝原因{{ app.reject_reason }}</span>
<span class="biz-time">{{ formatDate(app.created_at) }}</span>
</div>
</div>
</div>
</div>
@@ -111,6 +127,27 @@ const searchQuery = ref('')
const filterRole = ref('')
const translations = ref([])
const businessApps = ref([])
import { reactive } from 'vue'
const groupedBizApps = computed(() => {
const map = {}
for (const app of businessApps.value) {
const uid = app.user_id
if (!map[uid]) map[uid] = { user_id: uid, history: [], latest: null, expanded: false }
map[uid].history.push(app)
}
return Object.values(map).map(g => {
g.history.sort((a, b) => b.id - a.id)
g.latest = g.history[0]
return reactive(g)
}).filter(g => g.latest)
})
function formatDate(d) {
if (!d) return ''
return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const roles = [
{ value: 'admin', label: '管理员' },
{ value: 'senior_editor', label: '高级编辑' },
@@ -138,10 +175,6 @@ function roleLabel(role) {
return map[role] || role
}
function formatDate(d) {
if (!d) return '--'
return new Date(d).toLocaleDateString('zh-CN')
}
async function loadUsers() {
try {
@@ -352,8 +385,26 @@ onMounted(() => {
.review-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.biz-app-group { margin-bottom: 6px; }
.biz-status-tag {
font-size: 11px; padding: 2px 8px; border-radius: 8px; font-weight: 500; white-space: nowrap;
}
.biz-status-tag.small { font-size: 10px; padding: 1px 6px; }
.biz-pending { background: #fff3e0; color: #e65100; }
.biz-approved { background: #e8f5e9; color: #2e7d32; }
.biz-rejected { background: #fce4ec; color: #c62828; }
.biz-history {
margin: 4px 0 8px 16px; padding: 8px 12px; background: #fafaf8; border-radius: 8px; border-left: 3px solid #e5e4e7;
}
.biz-history-item {
display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; flex-wrap: wrap;
}
.biz-reject-reason { color: #c62828; font-size: 11px; }
.biz-time { color: #bbb; font-size: 11px; margin-left: auto; }
.btn-approve {
background: #4a9d7e;
color: #fff;