Files
oil-formula-calculator/frontend/src/views/UserManagement.vue
Hera Zhao ee8ec23dc7 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>
2026-04-06 18:35:00 +00:00

742 lines
16 KiB
Vue
Raw Blame History

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