初始提交:极码 GeekCode 全栈项目(FastAPI + Vue3)

This commit is contained in:
2026-04-12 10:12:18 +08:00
commit 6aecef16f6
104 changed files with 21009 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>极码 GeekCode</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3238
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.14.0",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.1",
"mermaid": "^11.13.0",
"pinia": "^3.0.4",
"vue": "^3.5.30",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.5",
"tailwindcss": "^4.2.2",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

30
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,30 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 60000,
})
// 请求拦截器自动添加Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器处理401
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config.url?.includes('/auth/')) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

449
frontend/src/api/modules.js Normal file
View File

@@ -0,0 +1,449 @@
import api from './index'
export const authApi = {
register(data) {
return api.post('/auth/register', data)
},
login(data) {
return api.post('/auth/login', data)
},
getMe() {
return api.get('/auth/me')
},
updateProfile(data) {
return api.put('/auth/profile', data)
},
}
export const requirementApi = {
getConversations() {
return api.get('/requirement/conversations')
},
getConversation(id) {
return api.get(`/requirement/conversations/${id}`)
},
deleteConversation(id) {
return api.delete(`/requirement/conversations/${id}`)
},
uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
return api.post('/requirement/upload-image', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
// analyze使用SSE流式不走axios
}
export const architectureApi = {
getConversations() {
return api.get('/architecture/conversations')
},
getConversation(id) {
return api.get(`/architecture/conversations/${id}`)
},
deleteConversation(id) {
return api.delete(`/architecture/conversations/${id}`)
},
}
export const postsApi = {
getPosts(params) {
return api.get('/posts', { params })
},
getPost(id) {
return api.get(`/posts/${id}`)
},
createPost(data) {
return api.post('/posts', data)
},
updatePost(id, data) {
return api.put(`/posts/${id}`, data)
},
deletePost(id) {
return api.delete(`/posts/${id}`)
},
toggleLike(id) {
return api.post(`/posts/${id}/like`)
},
toggleCollect(id) {
return api.post(`/posts/${id}/collect`)
},
getComments(id) {
return api.get(`/posts/${id}/comments`)
},
createComment(id, data) {
return api.post(`/posts/${id}/comments`, data)
},
getAttachments(id) {
return api.get(`/posts/${id}/attachments`)
},
deleteAttachment(postId, attachmentId) {
return api.delete(`/posts/${postId}/attachments/${attachmentId}`)
},
getDrafts(params) {
return api.get('/posts/drafts', { params })
},
}
export const searchApi = {
search(params) {
return api.get('/search', { params })
},
}
export const aiModelsApi = {
getPresets() {
return api.get('/admin/models/presets')
},
getTaskTypes() {
return api.get('/admin/models/task-types')
},
getModels(params) {
return api.get('/admin/models', { params })
},
createModel(data) {
return api.post('/admin/models', data)
},
updateModel(id, data) {
return api.put(`/admin/models/${id}`, data)
},
deleteModel(id) {
return api.delete(`/admin/models/${id}`)
},
initDefaults() {
return api.post('/admin/models/init-defaults')
},
testConnection(id) {
return api.post(`/admin/models/${id}/test`)
},
// 公开接口 - 获取可用模型列表(登录用户)
getAvailableModels(params) {
return api.get('/models/available', { params })
},
}
export const bookmarksApi = {
getBookmarks() {
return api.get('/bookmarks')
},
createBookmark(data) {
return api.post('/bookmarks', data)
},
updateBookmark(id, data) {
return api.put(`/bookmarks/${id}`, data)
},
deleteBookmark(id) {
return api.delete(`/bookmarks/${id}`)
},
reorder(items) {
return api.put('/bookmarks/reorder', { items })
},
}
export const usersApi = {
getProfile(id) {
return api.get(`/users/${id}`)
},
toggleFollow(id) {
return api.post(`/users/${id}/follow`)
},
getUserPosts(id, params) {
return api.get(`/users/${id}/posts`, { params })
},
getUserCollects(id, params) {
return api.get(`/users/${id}/collects`, { params })
},
getFollowers(id, params) {
return api.get(`/users/${id}/followers`, { params })
},
getFollowing(id, params) {
return api.get(`/users/${id}/following`, { params })
},
}
export const notificationsApi = {
getNotifications(params) {
return api.get('/notifications', { params })
},
getUnreadCount() {
return api.get('/notifications/unread-count')
},
readAll() {
return api.put('/notifications/read-all')
},
readOne(id) {
return api.put(`/notifications/${id}/read`)
},
}
export const feedApi = {
getFeed(params) {
return api.get('/posts/feed', { params })
},
getHot(params) {
return api.get('/posts/hot', { params })
},
getLatest(params) {
return api.get('/posts/latest', { params })
},
}
export const uploadApi = {
uploadImage(file) {
const formData = new FormData()
formData.append('file', file)
return api.post('/upload/image', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
uploadAttachment(file, postId) {
const formData = new FormData()
formData.append('file', file)
if (postId) formData.append('post_id', postId)
return api.post('/upload/attachment', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
updateAttachmentPost(attachmentId, postId) {
return api.put(`/upload/attachment/${attachmentId}/post`, null, { params: { post_id: postId } })
},
}
export const adminApi = {
getStats() {
return api.get('/admin/stats')
},
getUsers(params) {
return api.get('/admin/users', { params })
},
toggleAdmin(id) {
return api.put(`/admin/users/${id}/toggle-admin`)
},
toggleBan(id) {
return api.put(`/admin/users/${id}/toggle-ban`)
},
approveUser(id) {
return api.put(`/admin/users/${id}/approve`)
},
rejectUser(id) {
return api.put(`/admin/users/${id}/reject`)
},
getPosts(params) {
return api.get('/admin/posts', { params })
},
deletePost(id) {
return api.delete(`/admin/posts/${id}`)
},
// 对象存储管理
getStorageConfig() {
return api.get('/admin/storage/config')
},
updateStorageConfig(data) {
return api.put('/admin/storage/config', data)
},
testStorageConnection() {
return api.post('/admin/storage/test')
},
// 分类管理
getCategories() {
return api.get('/admin/categories')
},
createCategory(data) {
return api.post('/admin/categories', data)
},
updateCategory(id, data) {
return api.put(`/admin/categories/${id}`, data)
},
deleteCategory(id) {
return api.delete(`/admin/categories/${id}`)
},
}
// 公开分类API
export const categoryApi = {
getActiveCategories() {
return api.get('/admin/public/categories')
},
}
// 开源项目API
export const projectsApi = {
// 管理员接口
adminList(params) {
return api.get('/projects/admin/list', { params })
},
adminCreate(data) {
return api.post('/projects/admin', data)
},
adminUpdate(id, data) {
return api.put(`/projects/admin/${id}`, data)
},
adminDelete(id) {
return api.delete(`/projects/admin/${id}`)
},
githubSearch(params) {
return api.get('/projects/admin/github-search', { params })
},
githubImport(data) {
return api.post('/projects/admin/github-import', data)
},
// 公开接口
getHot(params) {
return api.get('/projects/hot', { params })
},
getLatest(params) {
return api.get('/projects/latest', { params })
},
search(params) {
return api.get('/projects/search', { params })
},
getCategories() {
return api.get('/projects/categories')
},
getDetail(id) {
return api.get(`/projects/${id}`)
},
// 公开GitHub搜索登录用户可用
publicGithubSearch(params) {
return api.get('/projects/github-search', { params })
},
// 收藏
toggleCollect(id) {
return api.post(`/projects/${id}/collect`)
},
getMyCollects(params) {
return api.get('/projects/my-collects', { params })
},
}
// 导航站API
export const navApi = {
// 管理员接口
getCategories() {
return api.get('/nav/admin/categories')
},
createCategory(data) {
return api.post('/nav/admin/categories', data)
},
updateCategory(id, data) {
return api.put(`/nav/admin/categories/${id}`, data)
},
deleteCategory(id) {
return api.delete(`/nav/admin/categories/${id}`)
},
getLinks(params) {
return api.get('/nav/admin/links', { params })
},
createLink(data) {
return api.post('/nav/admin/links', data)
},
updateLink(id, data) {
return api.put(`/nav/admin/links/${id}`, data)
},
deleteLink(id) {
return api.delete(`/nav/admin/links/${id}`)
},
// 公开接口
getPublicNav() {
return api.get('/nav/public')
},
getPublicCategories() {
return api.get('/nav/public/categories')
},
// 用户提交
submitLink(data) {
return api.post('/nav/submit', data)
},
getMySubmissions() {
return api.get('/nav/my-submissions')
},
// 审核
getPendingCount() {
return api.get('/nav/admin/pending-count')
},
reviewLink(id, data) {
return api.put(`/nav/admin/links/${id}/review`, data)
},
}
// API Hub
function hubHeaders() {
const t = sessionStorage.getItem('hub_token')
return t ? { 'X-Hub-Token': t } : {}
}
export const apiHubApi = {
// 密码认证
auth(password) { return api.post('/api-hub/auth', { password }) },
checkPassword() { return api.get('/api-hub/check-password') },
// 管理员设置密码
setPassword(password) { return api.put('/api-hub/admin/password', { password }) },
getPasswordStatus() { return api.get('/api-hub/admin/password-status') },
// 分类
getCategories() { return api.get('/api-hub/categories', { headers: hubHeaders() }) },
createCategory(data) { return api.post('/api-hub/categories', data, { headers: hubHeaders() }) },
updateCategory(id, data) { return api.put(`/api-hub/categories/${id}`, data, { headers: hubHeaders() }) },
deleteCategory(id) { return api.delete(`/api-hub/categories/${id}`, { headers: hubHeaders() }) },
// API CRUD
getApis(params) { return api.get('/api-hub/list', { params, headers: hubHeaders() }) },
createApi(data) { return api.post('/api-hub/', data, { headers: hubHeaders() }) },
updateApi(id, data) { return api.put(`/api-hub/${id}`, data, { headers: hubHeaders() }) },
deleteApi(id) { return api.delete(`/api-hub/${id}`, { headers: hubHeaders() }) },
// 测试和健康检查
testApi(id, data) { return api.post(`/api-hub/${id}/test`, data, { headers: hubHeaders() }) },
healthCheck(id) { return api.post(`/api-hub/${id}/health-check`, {}, { headers: hubHeaders() }) },
// 日志和统计
getLogs(id, params) { return api.get(`/api-hub/${id}/logs`, { params, headers: hubHeaders() }) },
getStats() { return api.get('/api-hub/stats', { headers: hubHeaders() }) },
}
// 团队知识库
function kbHeaders() {
const t = sessionStorage.getItem('kb_token')
return t ? { 'X-Kb-Token': t } : {}
}
export const kbApi = {
// 密码认证
auth(password) { return api.post('/kb/auth', { password }) },
checkPassword() { return api.get('/kb/check-password') },
// 公开接口
getCategories() { return api.get('/kb/categories', { headers: kbHeaders() }) },
getItems(params) { return api.get('/kb/items', { params, headers: kbHeaders() }) },
getItem(id) { return api.get(`/kb/items/${id}`, { headers: kbHeaders() }) },
getStats() { return api.get('/kb/stats', { headers: kbHeaders() }) },
// 管理员接口
setPassword(password) { return api.put('/kb/admin/password', { password }) },
getPasswordStatus() { return api.get('/kb/admin/password-status') },
adminGetCategories() { return api.get('/kb/admin/categories') },
adminCreateCategory(data) { return api.post('/kb/admin/categories', data) },
adminUpdateCategory(id, data) { return api.put(`/kb/admin/categories/${id}`, data) },
adminDeleteCategory(id) { return api.delete(`/kb/admin/categories/${id}`) },
adminGetItems(params) { return api.get('/kb/admin/items', { params }) },
adminAddItems(data) { return api.post('/kb/admin/items', data) },
adminUpdateItem(id, data) { return api.put(`/kb/admin/items/${id}`, data) },
adminDeleteItem(id) { return api.delete(`/kb/admin/items/${id}`) },
adminGetPostsForPick(params) { return api.get('/kb/admin/posts-for-pick', { params }) },
adminGetStats() { return api.get('/kb/admin/stats') },
}
// 联网搜索
export const webSearchApi = {
getConversations() {
return api.get('/web-search/conversations')
},
getConversation(id) {
return api.get(`/web-search/conversations/${id}`)
},
deleteConversation(id) {
return api.delete(`/web-search/conversations/${id}`)
},
// search 使用 SSE 不走 axios
}
export const aiFormatApi = {
formatArticle(data) {
return api.post('/ai/format', data, { timeout: 120000 })
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

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

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

View File

@@ -0,0 +1,198 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { guest: true },
},
{
path: '/',
component: () => import('../views/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: () => import('../views/Home.vue'),
},
{
path: 'discover',
redirect: '/',
},
{
path: 'tools',
name: 'ToolHub',
component: () => import('../views/ToolHub.vue'),
},
{
path: 'tools/requirement',
name: 'Requirement',
component: () => import('../views/RequirementAssistant.vue'),
},
{
path: 'tools/architecture',
name: 'Architecture',
component: () => import('../views/ArchitectureAssistant.vue'),
},
{
path: 'tools/api-hub',
name: 'ApiHub',
component: () => import('../views/ApiHub.vue'),
},
{
path: 'tools/web-search',
name: 'WebSearch',
component: () => import('../views/WebSearch.vue'),
},
{
path: 'post/new',
name: 'PostEditor',
component: () => import('../views/PostEditor.vue'),
},
{
path: 'post/edit/:id',
name: 'PostEdit',
component: () => import('../views/PostEditor.vue'),
},
{
path: 'drafts',
name: 'Drafts',
component: () => import('../views/Drafts.vue'),
},
{
path: 'post/:id',
name: 'PostDetail',
component: () => import('../views/PostDetail.vue'),
},
{
path: 'notifications',
name: 'Notifications',
component: () => import('../views/Notifications.vue'),
},
{
path: 'user/:id',
name: 'UserProfile',
component: () => import('../views/UserProfile.vue'),
},
{
path: 'profile',
name: 'Profile',
component: () => import('../views/Profile.vue'),
},
{
path: 'browser',
name: 'Browser',
component: () => import('../views/BrowserPage.vue'),
},
{
path: 'nav',
name: 'Navigation',
component: () => import('../views/Navigation.vue'),
},
{
path: 'projects',
name: 'Projects',
component: () => import('../views/Projects.vue'),
},
{
path: 'kb',
name: 'KnowledgeBase',
component: () => import('../views/KnowledgeBase.vue'),
},
],
},
{
path: '/admin',
component: () => import('../views/admin/AdminLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('../views/admin/AdminDashboard.vue'),
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('../views/admin/AdminUsers.vue'),
},
{
path: 'posts',
name: 'AdminPosts',
component: () => import('../views/admin/AdminPosts.vue'),
},
{
path: 'models',
name: 'AdminModels',
component: () => import('../views/ModelManagement.vue'),
},
{
path: 'storage',
name: 'AdminStorage',
component: () => import('../views/admin/AdminStorage.vue'),
},
{
path: 'categories',
name: 'AdminCategories',
component: () => import('../views/admin/AdminCategories.vue'),
},
{
path: 'nav',
name: 'AdminNav',
component: () => import('../views/admin/AdminNav.vue'),
},
{
path: 'projects',
name: 'AdminProjects',
component: () => import('../views/admin/AdminProjects.vue'),
},
{
path: 'api-hub',
name: 'AdminApiHub',
component: () => import('../views/admin/AdminApiHub.vue'),
},
{
path: 'kb',
name: 'AdminKnowledgeBase',
component: () => import('../views/admin/AdminKnowledgeBase.vue'),
},
],
},
// 未匹配路由重定向到首页
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
const isGuest = to.matched.some(record => record.meta.guest)
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
if (requiresAuth && !token) {
next('/login')
} else if (isGuest && token) {
next('/')
} else if (requiresAdmin) {
const user = JSON.parse(localStorage.getItem('user') || 'null')
if (!user?.is_admin) {
next('/')
} else {
next()
}
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useTabsStore = defineStore('tabs', () => {
// 固定标签页(不可关闭)
const fixedTabs = [
{ id: 'requirement', type: 'route', title: '需求助手', path: '/requirement', closable: false },
{ id: 'architecture', type: 'route', title: '架构助手', path: '/architecture', closable: false },
{ id: 'knowledge', type: 'route', title: '经验知识库', path: '/knowledge', closable: false },
]
// 动态标签页(可关闭的,包括浏览器标签和管理页面)
const dynamicTabs = ref([])
// 当前激活的标签 ID
const activeTabId = ref('requirement')
// 所有标签
const allTabs = computed(() => [...fixedTabs, ...dynamicTabs.value])
// 当前激活的标签
const activeTab = computed(() => allTabs.value.find(t => t.id === activeTabId.value))
// 浏览器标签(用于 v-show 保持 iframe 实例)
const browserTabs = computed(() => dynamicTabs.value.filter(t => t.type === 'browser'))
// 最大 iframe 数量
const MAX_BROWSER_TABS = 8
function setActiveTab(tabId) {
activeTabId.value = tabId
}
function openRouteTab(id, title, path) {
// 如果已存在,直接切换
const existing = allTabs.value.find(t => t.id === id)
if (existing) {
activeTabId.value = id
return
}
dynamicTabs.value.push({ id, type: 'route', title, path, closable: true })
activeTabId.value = id
}
function openBrowserTab(name, url) {
// 检查是否已打开同 URL 的标签
const existing = dynamicTabs.value.find(t => t.type === 'browser' && t.url === url)
if (existing) {
activeTabId.value = existing.id
return
}
// 检查数量限制
if (browserTabs.value.length >= MAX_BROWSER_TABS) {
return { error: `最多同时打开 ${MAX_BROWSER_TABS} 个网站标签,请先关闭一些` }
}
const id = `browser_${Date.now()}`
dynamicTabs.value.push({ id, type: 'browser', title: name, url, closable: true })
activeTabId.value = id
return { id }
}
function closeTab(tabId) {
const idx = dynamicTabs.value.findIndex(t => t.id === tabId)
if (idx === -1) return
dynamicTabs.value.splice(idx, 1)
// 如果关闭的是当前标签,切换到前一个或第一个
if (activeTabId.value === tabId) {
if (dynamicTabs.value.length > 0 && idx > 0) {
activeTabId.value = dynamicTabs.value[Math.min(idx - 1, dynamicTabs.value.length - 1)].id
} else {
activeTabId.value = fixedTabs[0].id
}
}
}
function switchToRequirement(prefillText) {
activeTabId.value = 'requirement'
return prefillText
}
return {
fixedTabs,
dynamicTabs,
activeTabId,
allTabs,
activeTab,
browserTabs,
setActiveTab,
openRouteTab,
openBrowserTab,
closeTab,
switchToRequirement,
}
})

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authApi } from '../api/modules'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const isLoggedIn = ref(!!token.value)
async function login(username, password) {
const res = await authApi.login({ username, password })
token.value = res.data.access_token
user.value = res.data.user
isLoggedIn.value = true
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(user.value))
}
async function register(username, email, password) {
const res = await authApi.register({ username, email, password })
token.value = res.data.access_token
user.value = res.data.user
isLoggedIn.value = true
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(user.value))
}
function logout() {
token.value = ''
user.value = null
isLoggedIn.value = false
localStorage.removeItem('token')
localStorage.removeItem('user')
}
function updateUser(newUser) {
user.value = newUser
localStorage.setItem('user', JSON.stringify(newUser))
}
return { token, user, isLoggedIn, login, register, logout, updateUser }
})

84
frontend/src/style.css Normal file
View File

@@ -0,0 +1,84 @@
@import "tailwindcss";
/* 自定义基础样式 */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Markdown渲染样式 */
.markdown-body h1 { font-size: 1.5em; font-weight: 700; margin: 1em 0 0.5em; }
.markdown-body h2 { font-size: 1.3em; font-weight: 600; margin: 1em 0 0.5em; }
.markdown-body h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.4em; }
.markdown-body p { margin: 1em 0; line-height: 1.8; }
.markdown-body ul, .markdown-body ol { padding-left: 1.5em; margin: 0.5em 0; }
.markdown-body li { margin: 0.3em 0; }
.markdown-body code {
background: rgba(127, 127, 127, 0.15);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
}
.markdown-body pre {
background: #1e1e2e;
color: #cdd6f4;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 0.8em 0;
}
.markdown-body pre code {
background: none;
padding: 0;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
}
.markdown-body th, .markdown-body td {
border: 1px solid rgba(127, 127, 127, 0.3);
padding: 0.5em 0.8em;
text-align: left;
}
.markdown-body th {
background: rgba(127, 127, 127, 0.1);
font-weight: 600;
}
.markdown-body blockquote {
border-left: 3px solid #6366f1;
padding-left: 1em;
margin: 0.5em 0;
color: rgba(127, 127, 127, 0.8);
}
/* 思考过程折叠样式 */
.markdown-body details {
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 8px;
padding: 0;
margin: 0.5em 0 1em;
overflow: hidden;
}
.markdown-body details summary {
padding: 0.6em 1em;
cursor: pointer;
font-weight: 500;
color: #a5b4fc;
user-select: none;
font-size: 0.85em;
}
.markdown-body details summary:hover {
background: rgba(99, 102, 241, 0.1);
}
.markdown-body details[open] summary {
border-bottom: 1px solid rgba(99, 102, 241, 0.15);
margin-bottom: 0.5em;
}
.markdown-body details > *:not(summary) {
padding: 0 1em;
font-size: 0.85em;
color: #9ca3af;
line-height: 1.6;
}

View File

@@ -0,0 +1,512 @@
<template>
<div class="h-full overflow-y-auto">
<!-- 密码验证层 -->
<div v-if="!unlocked" class="flex items-center justify-center h-full">
<div class="w-full max-w-xs text-center">
<div class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-indigo-600/20 flex items-center justify-center">
<svg class="w-7 h-7 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
</div>
<h2 class="text-base font-bold text-gray-100 mb-1">API Hub</h2>
<p class="text-xs text-gray-500 mb-5">输入团队访问密码以继续</p>
<div v-if="!hasPassword" class="text-xs text-yellow-400 mb-4">管理员尚未设置访问密码请联系管理员在后台设置</div>
<template v-else>
<input v-model="password" @keydown.enter="verifyPassword" type="password" placeholder="请输入访问密码" class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500 mb-3" />
<p v-if="authError" class="text-xs text-red-400 mb-3">{{ authError }}</p>
<button @click="verifyPassword" :disabled="!password.trim() || verifying" class="w-full py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ verifying ? '验证中...' : '进入 API Hub' }}
</button>
</template>
</div>
</div>
<!-- 主内容 -->
<div v-else class="max-w-5xl mx-auto px-6 py-6">
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5">
<div>
<h1 class="text-lg font-bold text-gray-100">API Hub</h1>
<p class="text-xs text-gray-500 mt-1">团队共享 API 资源管理</p>
</div>
<div class="flex items-center gap-3">
<!-- 统计 -->
<div class="flex items-center gap-3 text-[11px] text-gray-500 mr-2">
<span>{{ stats.total_apis }} 个API</span>
<span>{{ stats.total_calls }} 次调用</span>
<span class="text-green-400">{{ stats.healthy_count }} 健康</span>
</div>
<!-- 搜索 -->
<div class="relative w-48">
<svg class="w-3.5 h-3.5 text-gray-600 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input v-model="keyword" @input="loadApis" placeholder="搜索API..." class="w-full pl-8 pr-3 py-1.5 bg-gray-900 border border-gray-800 rounded-lg text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<button @click="openApiModal()" class="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
添加 API
</button>
</div>
</div>
<!-- 分类筛选 -->
<div class="flex items-center gap-2 mb-4 flex-wrap">
<button @click="filterCat = null; loadApis()" :class="filterCat === null ? 'bg-indigo-600/20 text-indigo-400 border-indigo-600/30' : 'bg-gray-900 text-gray-400 border-gray-800 hover:border-gray-700'" class="px-3 py-1 text-xs rounded-lg border transition-colors">全部</button>
<button v-for="cat in categories" :key="cat.id" @click="filterCat = cat.id; loadApis()" :class="filterCat === cat.id ? 'bg-indigo-600/20 text-indigo-400 border-indigo-600/30' : 'bg-gray-900 text-gray-400 border-gray-800 hover:border-gray-700'" class="px-3 py-1 text-xs rounded-lg border transition-colors">
{{ cat.name }} <span class="text-gray-600 ml-0.5">{{ cat.api_count }}</span>
</button>
<button @click="showCatModal = true" class="px-2 py-1 text-xs text-gray-600 hover:text-gray-400 border border-dashed border-gray-800 rounded-lg transition-colors">+ 分类</button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="flex justify-center py-16">
<div class="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- API 卡片网格 -->
<div v-else-if="apis.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="a in apis" :key="a.id" @click="selectApi(a)" class="bg-gray-900 border border-gray-800 rounded-xl px-5 py-4 cursor-pointer hover:border-indigo-600/30 transition-all group">
<div class="flex items-start gap-3">
<!-- 状态灯 -->
<div class="w-2 h-2 rounded-full mt-1.5 shrink-0" :class="a.last_check_status === 'ok' ? 'bg-green-400' : a.last_check_status === 'error' ? 'bg-red-400' : 'bg-gray-600'"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-200 group-hover:text-indigo-400 transition-colors truncate">{{ a.name }}</span>
<span class="px-1.5 py-0.5 text-[10px] rounded shrink-0" :class="authTypeClass(a.auth_type)">{{ authTypeLabel(a.auth_type) }}</span>
</div>
<div class="text-[11px] text-gray-600 truncate mt-0.5">{{ getDomain(a.base_url) }}</div>
<div v-if="a.description" class="text-[11px] text-gray-500 mt-1 line-clamp-2">{{ a.description }}</div>
<div class="flex items-center gap-2 mt-2">
<span v-for="tag in parseTags(a.tags)" :key="tag" class="px-1.5 py-0.5 bg-gray-800 text-[10px] text-gray-500 rounded">{{ tag }}</span>
<span class="text-[10px] text-gray-600 ml-auto">{{ a.call_count }} 次调用</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-16">
<svg class="w-12 h-12 text-gray-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
<p class="text-sm text-gray-500">暂无API点击右上角添加</p>
</div>
</div>
<!-- API 详情/测试侧边面板 -->
<Teleport to="body">
<div v-if="selectedApi" class="fixed inset-0 z-50 flex justify-end bg-black/50" @click.self="selectedApi = null">
<div class="w-full max-w-xl bg-gray-900 border-l border-gray-800 h-full overflow-y-auto p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-gray-100">{{ selectedApi.name }}</h3>
<div class="flex items-center gap-2">
<button @click="openApiModal(selectedApi)" class="p-1.5 text-gray-500 hover:text-indigo-400 transition-colors" title="编辑">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="doHealthCheck(selectedApi)" class="p-1.5 text-gray-500 hover:text-green-400 transition-colors" title="健康检查">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
</button>
<button @click="selectedApi = null" class="p-1.5 text-gray-500 hover:text-gray-300 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<!-- 基本信息 -->
<div class="space-y-2 mb-5">
<div class="flex items-center gap-2 text-xs">
<span class="text-gray-500">URL:</span>
<a :href="selectedApi.base_url" target="_blank" class="text-indigo-400 hover:underline truncate">{{ selectedApi.base_url }}</a>
</div>
<div v-if="selectedApi.doc_url" class="flex items-center gap-2 text-xs">
<span class="text-gray-500">文档:</span>
<a :href="selectedApi.doc_url" target="_blank" class="text-indigo-400 hover:underline truncate">{{ selectedApi.doc_url }}</a>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-gray-500">认证:</span>
<span :class="authTypeClass(selectedApi.auth_type)" class="px-1.5 py-0.5 text-[10px] rounded">{{ authTypeLabel(selectedApi.auth_type) }}</span>
</div>
<div v-if="selectedApi.has_api_key" class="flex items-center gap-2 text-xs">
<span class="text-gray-500">API Key:</span>
<code class="flex-1 px-2 py-1 bg-gray-800 rounded text-[11px] font-mono text-amber-400 truncate select-all">{{ selectedApi.api_key_plain }}</code>
<button @click="copyKey(selectedApi.api_key_plain)" class="px-2 py-1 text-[10px] bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 rounded transition-colors shrink-0">
{{ copiedKey ? '已复制' : '复制' }}
</button>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-gray-500">状态:</span>
<span :class="selectedApi.last_check_status === 'ok' ? 'text-green-400' : selectedApi.last_check_status === 'error' ? 'text-red-400' : 'text-gray-500'">
{{ selectedApi.last_check_status === 'ok' ? '正常' : selectedApi.last_check_status === 'error' ? '异常' : '未检测' }}
</span>
</div>
</div>
<!-- API测试面板 -->
<div class="bg-gray-800/50 border border-gray-800 rounded-xl p-4 mb-5">
<h4 class="text-xs font-medium text-gray-300 mb-3">在线测试</h4>
<div class="flex items-center gap-2 mb-3">
<select v-model="testMethod" class="px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
<input v-model="testPath" placeholder="/path (可选)" class="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<button @click="doTest" :disabled="testing" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors shrink-0">
{{ testing ? '请求中...' : '发送' }}
</button>
</div>
<textarea v-if="testMethod !== 'GET'" v-model="testBody" placeholder="请求体 (JSON)" rows="3" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500 font-mono mb-3"></textarea>
<!-- 测试结果 -->
<div v-if="testResult" class="bg-gray-900 border border-gray-700 rounded-lg p-3">
<div class="flex items-center gap-3 mb-2 text-xs">
<span :class="testResult.status_code >= 200 && testResult.status_code < 400 ? 'text-green-400' : 'text-red-400'" class="font-mono font-bold">{{ testResult.status_code || 'ERR' }}</span>
<span class="text-gray-500">{{ testResult.response_time_ms }}ms</span>
</div>
<pre class="text-[11px] text-gray-400 font-mono whitespace-pre-wrap break-all max-h-60 overflow-y-auto">{{ testResult.body }}</pre>
</div>
</div>
<!-- 使用日志 -->
<div>
<h4 class="text-xs font-medium text-gray-300 mb-3">最近日志</h4>
<div v-if="apiLogs.length > 0" class="space-y-1">
<div v-for="log in apiLogs" :key="log.id" class="flex items-center gap-3 text-[11px] py-1.5 border-b border-gray-800/50">
<span :class="log.response_status >= 200 && log.response_status < 400 ? 'text-green-400' : 'text-red-400'" class="font-mono w-8">{{ log.response_status || 'ERR' }}</span>
<span class="text-gray-500">{{ log.response_time_ms }}ms</span>
<span class="text-gray-600 truncate flex-1">{{ log.request_url }}</span>
<span class="text-gray-700 shrink-0">{{ formatTime(log.created_at) }}</span>
</div>
</div>
<p v-else class="text-xs text-gray-600">暂无日志</p>
</div>
</div>
</div>
</Teleport>
<!-- 新增/编辑API弹窗 -->
<Teleport to="body">
<div v-if="showApiModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showApiModal = false">
<div class="w-full max-w-lg bg-gray-900 border border-gray-800 rounded-xl p-6 mx-4 max-h-[85vh] overflow-y-auto">
<h3 class="text-base font-bold text-gray-100 mb-4">{{ editingApi ? '编辑 API' : '添加 API' }}</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">名称 <span class="text-red-400">*</span></label>
<input v-model="apiForm.name" placeholder="如: OpenAI" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">分类</label>
<select v-model="apiForm.category_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500">
<option :value="null">未分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Base URL</label>
<input v-model="apiForm.base_url" placeholder="https://api.example.com/v1" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">描述</label>
<input v-model="apiForm.description" placeholder="简短描述API用途" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">文档链接</label>
<input v-model="apiForm.doc_url" placeholder="https://docs.example.com" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">健康检查URL</label>
<input v-model="apiForm.health_check_url" placeholder="留空则使用Base URL" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">认证方式</label>
<select v-model="apiForm.auth_type" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500">
<option value="none">无认证</option>
<option value="api_key">API Key</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Key 请求头</label>
<input v-model="apiForm.api_key_header" placeholder="Authorization" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">API Key</label>
<input v-model="apiForm.api_key" type="text" placeholder="明文输入,加密存储" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500 font-mono" />
</div>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">标签</label>
<input v-model="apiForm.tags" placeholder="逗号分隔,如: AI,NLP,翻译" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
</div>
<p v-if="apiFormError" class="text-xs text-red-400 mt-2">{{ apiFormError }}</p>
<div class="flex justify-between mt-5">
<button v-if="editingApi" @click="deleteApiItem(editingApi)" class="px-3 py-2 text-xs text-red-400 hover:text-red-300 transition-colors">删除此API</button>
<div v-else></div>
<div class="flex gap-3">
<button @click="showApiModal = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200">取消</button>
<button @click="saveApi" :disabled="apiSaving" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ apiSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- 分类管理弹窗 -->
<Teleport to="body">
<div v-if="showCatModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showCatModal = false">
<div class="w-full max-w-sm bg-gray-900 border border-gray-800 rounded-xl p-5 mx-4">
<h3 class="text-sm font-bold text-gray-100 mb-4">管理分类</h3>
<div class="space-y-1.5 mb-4 max-h-48 overflow-y-auto">
<div v-for="cat in categories" :key="cat.id" class="flex items-center gap-2 bg-gray-800/50 rounded-lg px-3 py-2">
<span class="text-sm text-gray-200 flex-1">{{ cat.name }}</span>
<span class="text-[10px] text-gray-600">{{ cat.api_count }}</span>
<button @click="deleteCat(cat)" class="p-0.5 text-gray-600 hover:text-red-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
<div class="flex gap-2">
<input v-model="newCatName" @keydown.enter="addCat" placeholder="新分类名称" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<button @click="addCat" :disabled="!newCatName.trim()" class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors">添加</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { apiHubApi } from '../api/modules'
// 密码验证
const unlocked = ref(false)
const hasPassword = ref(true)
const password = ref('')
const verifying = ref(false)
const authError = ref('')
// 主数据
const loading = ref(false)
const keyword = ref('')
const filterCat = ref(null)
const categories = ref([])
const apis = ref([])
const stats = ref({ total_apis: 0, total_calls: 0, total_categories: 0, healthy_count: 0 })
// 详情面板
const selectedApi = ref(null)
const apiLogs = ref([])
const testMethod = ref('GET')
const testPath = ref('')
const testBody = ref('')
const testing = ref(false)
const testResult = ref(null)
const copiedKey = ref(false)
// 编辑弹窗
const showApiModal = ref(false)
const editingApi = ref(null)
const apiSaving = ref(false)
const apiFormError = ref('')
const apiForm = ref({ name: '', base_url: '', description: '', doc_url: '', category_id: null, auth_type: 'none', api_key: '', api_key_header: 'Authorization', health_check_url: '', tags: '' })
// 分类弹窗
const showCatModal = ref(false)
const newCatName = ref('')
onMounted(async () => {
// 检查 sessionStorage 是否有 token
const token = sessionStorage.getItem('hub_token')
if (token) {
unlocked.value = true
loadAll()
return
}
// 检查是否设置了密码
try {
const { data } = await apiHubApi.checkPassword()
hasPassword.value = data.has_password
} catch (e) { /* 忽略 */ }
})
async function verifyPassword() {
if (!password.value.trim()) return
verifying.value = true
authError.value = ''
try {
const { data } = await apiHubApi.auth(password.value)
sessionStorage.setItem('hub_token', data.hub_token)
unlocked.value = true
loadAll()
} catch (e) {
authError.value = e.response?.data?.detail || '验证失败'
} finally { verifying.value = false }
}
async function loadAll() {
loading.value = true
try {
const [catRes, apiRes, statRes] = await Promise.all([
apiHubApi.getCategories(),
apiHubApi.getApis({}),
apiHubApi.getStats(),
])
categories.value = catRes.data
apis.value = apiRes.data.items
stats.value = statRes.data
} catch (e) {
if (e.response?.status === 403) {
sessionStorage.removeItem('hub_token')
unlocked.value = false
}
} finally { loading.value = false }
}
async function loadApis() {
try {
const params = {}
if (keyword.value.trim()) params.keyword = keyword.value.trim()
if (filterCat.value !== null) params.category_id = filterCat.value
const { data } = await apiHubApi.getApis(params)
apis.value = data.items
} catch (e) { /* 忽略 */ }
}
async function selectApi(a) {
selectedApi.value = a
testResult.value = null
testPath.value = ''
testBody.value = ''
testMethod.value = 'GET'
try {
const { data } = await apiHubApi.getLogs(a.id, { limit: 10 })
apiLogs.value = data
} catch (e) { apiLogs.value = [] }
}
// API CRUD
function openApiModal(api = null) {
editingApi.value = api
apiFormError.value = ''
if (api) {
apiForm.value = {
name: api.name, base_url: api.base_url, description: api.description || '',
doc_url: api.doc_url || '', category_id: api.category_id,
auth_type: api.auth_type || 'none', api_key: api.api_key_plain || '', api_key_header: api.api_key_header || 'Authorization',
health_check_url: api.health_check_url || '', tags: api.tags || '',
}
} else {
apiForm.value = { name: '', base_url: '', description: '', doc_url: '', category_id: null, auth_type: 'none', api_key: '', api_key_header: 'Authorization', health_check_url: '', tags: '' }
}
showApiModal.value = true
}
async function saveApi() {
if (!apiForm.value.name.trim()) {
apiFormError.value = '请填写名称'
return
}
apiSaving.value = true
apiFormError.value = ''
try {
if (editingApi.value) {
await apiHubApi.updateApi(editingApi.value.id, apiForm.value)
} else {
await apiHubApi.createApi(apiForm.value)
}
showApiModal.value = false
selectedApi.value = null
await loadAll()
} catch (e) {
apiFormError.value = e.response?.data?.detail || '保存失败'
} finally { apiSaving.value = false }
}
async function deleteApiItem(api) {
if (!confirm(`确定删除 API「${api.name}」?`)) return
try {
await apiHubApi.deleteApi(api.id)
showApiModal.value = false
selectedApi.value = null
await loadAll()
} catch (e) { alert(e.response?.data?.detail || '删除失败') }
}
// 测试
async function doTest() {
if (!selectedApi.value) return
testing.value = true
testResult.value = null
try {
const { data } = await apiHubApi.testApi(selectedApi.value.id, {
method: testMethod.value, path: testPath.value, body: testBody.value, headers: {},
})
testResult.value = data
// 刷新日志
const logsRes = await apiHubApi.getLogs(selectedApi.value.id, { limit: 10 })
apiLogs.value = logsRes.data
} catch (e) {
testResult.value = { status_code: 0, response_time_ms: 0, body: e.message || '请求失败' }
} finally { testing.value = false }
}
async function doHealthCheck(api) {
try {
const { data } = await apiHubApi.healthCheck(api.id)
api.last_check_status = data.status
alert(data.status === 'ok' ? `健康检查通过 (${data.response_time_ms}ms)` : `健康检查失败`)
await loadApis()
} catch (e) { alert('健康检查失败') }
}
// 分类
async function addCat() {
if (!newCatName.value.trim()) return
try {
await apiHubApi.createCategory({ name: newCatName.value.trim() })
newCatName.value = ''
const { data } = await apiHubApi.getCategories()
categories.value = data
} catch (e) { alert(e.response?.data?.detail || '添加失败') }
}
async function deleteCat(cat) {
if (!confirm(`确定删除分类「${cat.name}」?`)) return
try {
await apiHubApi.deleteCategory(cat.id)
const { data } = await apiHubApi.getCategories()
categories.value = data
} catch (e) { alert(e.response?.data?.detail || '删除失败') }
}
// 复制Key
function copyKey(key) {
navigator.clipboard.writeText(key)
copiedKey.value = true
setTimeout(() => copiedKey.value = false, 2000)
}
// 工具函数
function getDomain(url) {
try { return new URL(url).hostname } catch { return url }
}
function parseTags(tags) {
if (!tags) return []
return tags.split(',').map(t => t.trim()).filter(Boolean).slice(0, 4)
}
function authTypeLabel(t) {
return { none: '无认证', api_key: 'API Key', bearer: 'Bearer', basic: 'Basic' }[t] || t
}
function authTypeClass(t) {
return t === 'none' ? 'bg-gray-800 text-gray-500' : 'bg-amber-900/30 text-amber-400'
}
function formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>

View File

@@ -0,0 +1,261 @@
<template>
<div class="h-full flex">
<!-- 左侧对话列表 -->
<div class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
<div class="p-4">
<button
@click="startNewChat"
class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm rounded-lg transition-colors"
>
+ 新建架构咨询
</button>
</div>
<div class="flex-1 overflow-y-auto px-2">
<div
v-for="conv in conversations"
:key="conv.id"
@click="selectConversation(conv)"
class="px-3 py-2 mb-1 rounded-lg cursor-pointer text-sm truncate flex items-center justify-between group"
:class="currentConvId === conv.id ? 'bg-gray-800 text-white' : 'text-gray-400 hover:bg-gray-800/50'"
>
<span class="truncate">{{ conv.title }}</span>
<button
@click.stop="deleteConversation(conv.id)"
class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 ml-2 shrink-0"
>
x
</button>
</div>
<p v-if="conversations.length === 0" class="text-gray-600 text-sm text-center py-8">
暂无对话记录
</p>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="flex-1 flex flex-col">
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- 欢迎提示 -->
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center max-w-lg">
<h2 class="text-2xl font-bold text-gray-300 mb-4">架构选型助手</h2>
<p class="text-gray-500 mb-6">
描述你的项目需求和约束条件AI帮你推荐技术栈生成架构图评估技术风险
</p>
<div class="grid grid-cols-2 gap-3 text-sm">
<div @click="quickAsk('我想做一个电商平台,推荐技术栈')" class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-indigo-500 transition-colors">
推荐电商平台技术栈
</div>
<div @click="quickAsk('React和Vue哪个更适合后台管理系统')" class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-indigo-500 transition-colors">
React vs Vue对比
</div>
<div @click="quickAsk('帮我设计一个微服务架构图')" class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-indigo-500 transition-colors">
生成微服务架构图
</div>
<div @click="quickAsk('MySQL和PostgreSQL哪个更合适')" class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-indigo-500 transition-colors">
数据库选型建议
</div>
</div>
</div>
</div>
<!-- 消息列表 -->
<div v-for="msg in messages" :key="msg.id || msg.tempId" class="flex gap-3">
<div v-if="msg.role === 'user'" class="ml-auto max-w-2xl">
<div class="bg-indigo-600 rounded-2xl rounded-tr-md px-4 py-3 text-sm whitespace-pre-wrap">
{{ msg.content }}
</div>
</div>
<div v-else class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(msg.content)"></div>
</div>
</div>
</div>
<div v-if="isStreaming" class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(streamingContent)"></div>
<span class="inline-block w-2 h-4 bg-indigo-400 animate-pulse ml-0.5"></span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="border-t border-gray-800 p-4 bg-gray-900/50">
<!-- 模型选择器 -->
<div v-if="availableModels.length > 0" class="flex items-center gap-2 mb-3">
<span class="text-xs text-gray-500">模型:</span>
<select v-model="selectedModelId" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option :value="null">默认</option>
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.model_name || m.model_id }}
<template v-if="m.web_search_enabled"> 🌐</template>
<template v-if="m.is_default"> (默认)</template>
</option>
</select>
<span v-if="selectedModel?.web_search_enabled" class="text-xs text-blue-400">🌐 联网搜索</span>
</div>
<div class="flex gap-3 items-end">
<textarea
v-model="inputText"
@keydown.enter.exact="handleSend"
placeholder="描述项目需求、技术选型问题..."
rows="1"
class="flex-1 px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-xl text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 resize-none max-h-32 text-sm"
:disabled="isStreaming"
></textarea>
<button
@click="handleSend"
:disabled="isStreaming || !inputText.trim()"
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-30 text-white rounded-xl transition-colors shrink-0 text-sm"
>
发送
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import { architectureApi, aiModelsApi } from '../api/modules'
const md = new MarkdownIt({ html: true, breaks: true, linkify: true })
const conversations = ref([])
const currentConvId = ref(null)
const messages = ref([])
const inputText = ref('')
const isStreaming = ref(false)
const streamingContent = ref('')
const messagesContainer = ref(null)
const availableModels = ref([])
const selectedModelId = ref(null)
const selectedModel = computed(() =>
availableModels.value.find(m => m.id === selectedModelId.value)
)
onMounted(() => {
loadConversations()
loadModels()
})
async function loadModels() {
try {
const res = await aiModelsApi.getAvailableModels({ task_type: 'reasoning' })
availableModels.value = res.data
} catch (e) {
console.error('加载模型列表失败', e)
}
}
function renderMarkdown(text) {
if (!text) return ''
return md.render(text)
}
async function loadConversations() {
try {
const res = await architectureApi.getConversations()
conversations.value = res.data
} catch (e) { console.error(e) }
}
async function selectConversation(conv) {
currentConvId.value = conv.id
try {
const res = await architectureApi.getConversation(conv.id)
messages.value = res.data.messages
scrollToBottom()
} catch (e) { console.error(e) }
}
function startNewChat() {
currentConvId.value = null
messages.value = []
inputText.value = ''
}
async function deleteConversation(id) {
try {
await architectureApi.deleteConversation(id)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentConvId.value === id) startNewChat()
} catch (e) { console.error(e) }
}
function quickAsk(text) {
inputText.value = text
handleSend()
}
async function handleSend(e) {
if (e && e.shiftKey) return
if (e) e.preventDefault()
if (isStreaming.value || !inputText.value.trim()) return
const text = inputText.value.trim()
messages.value.push({ tempId: Date.now(), role: 'user', content: text })
inputText.value = ''
scrollToBottom()
isStreaming.value = true
streamingContent.value = ''
const token = localStorage.getItem('token')
try {
const response = await fetch('/api/architecture/recommend', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
conversation_id: currentConvId.value,
content: text,
model_config_id: selectedModelId.value,
}),
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const lines = decoder.decode(value).split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.done) {
if (data.conversation_id) currentConvId.value = data.conversation_id
} else {
streamingContent.value += data.content
scrollToBottom()
}
} catch {}
}
}
}
messages.value.push({ tempId: Date.now(), role: 'assistant', content: streamingContent.value })
streamingContent.value = ''
isStreaming.value = false
loadConversations()
} catch (e) {
console.error(e)
isStreaming.value = false
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
})
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center" @click.self="$emit('close')">
<div class="absolute inset-0 bg-black/60"></div>
<div class="relative w-[520px] max-h-[80vh] bg-gray-900 rounded-xl border border-gray-700 shadow-2xl flex flex-col">
<!-- 标题 -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-800">
<h3 class="text-base font-medium text-gray-100">网站收藏</h3>
<button @click="$emit('close')" class="text-gray-500 hover:text-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 快速添加 -->
<div class="px-5 py-3 border-b border-gray-800">
<div class="flex gap-2">
<input v-model="newName" placeholder="名称" class="w-28 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<input v-model="newUrl" placeholder="https://..." class="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" @keydown.enter="addBookmark" />
<button @click="addBookmark" :disabled="!newName || !newUrl" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors whitespace-nowrap">
添加
</button>
</div>
</div>
<!-- 收藏列表 -->
<div class="flex-1 overflow-y-auto px-5 py-3">
<div v-if="bookmarks.length === 0" class="text-center py-12">
<svg class="w-12 h-12 text-gray-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
<p class="text-sm text-gray-500">还没有收藏的网站</p>
<p class="text-xs text-gray-600 mt-1">在上方输入名称和网址添加</p>
</div>
<div v-else class="space-y-2">
<div v-for="bm in bookmarks" :key="bm.id"
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-gray-800/60 transition-colors cursor-pointer"
@click="openSite(bm)">
<!-- 图标 -->
<div class="w-8 h-8 rounded-lg bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0">
<img v-if="bm.icon" :src="bm.icon" class="w-5 h-5 rounded" @error="$event.target.style.display='none'" />
<span v-else class="text-xs font-bold text-indigo-400">{{ bm.name.charAt(0).toUpperCase() }}</span>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-200 truncate">{{ bm.name }}</div>
<div class="text-xs text-gray-500 truncate">{{ bm.url }}</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click.stop="startEdit(bm)" class="p-1 text-gray-500 hover:text-gray-300 rounded" title="编辑">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click.stop="deleteBookmark(bm)" class="p-1 text-gray-500 hover:text-red-400 rounded" title="删除">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-if="editingBookmark" class="absolute inset-0 bg-gray-900/95 rounded-xl flex items-center justify-center p-5">
<div class="w-full max-w-sm space-y-4">
<h4 class="text-sm font-medium text-gray-200">编辑收藏</h4>
<input v-model="editForm.name" placeholder="名称" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
<input v-model="editForm.url" placeholder="网址" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
<div class="flex gap-2 justify-end">
<button @click="editingBookmark = null" class="px-4 py-1.5 text-sm text-gray-400 hover:text-gray-200 transition-colors">取消</button>
<button @click="saveEdit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm rounded-lg transition-colors">保存</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { bookmarksApi } from '../api/modules'
import { useTabsStore } from '../stores/tabs'
const props = defineProps({ show: Boolean })
const emit = defineEmits(['close'])
const tabsStore = useTabsStore()
const bookmarks = ref([])
const newName = ref('')
const newUrl = ref('')
const editingBookmark = ref(null)
const editForm = ref({ name: '', url: '' })
watch(() => props.show, (val) => {
if (val) loadBookmarks()
})
async function loadBookmarks() {
try {
bookmarks.value = (await bookmarksApi.getBookmarks()).data
} catch (e) { console.error(e) }
}
async function addBookmark() {
if (!newName.value || !newUrl.value) return
let url = newUrl.value
if (!/^https?:\/\//.test(url)) url = 'https://' + url
try {
await bookmarksApi.createBookmark({ name: newName.value, url })
newName.value = ''
newUrl.value = ''
await loadBookmarks()
} catch (e) { console.error(e) }
}
function openSite(bm) {
const result = tabsStore.openBrowserTab(bm.name, bm.url)
if (result?.error) {
alert(result.error)
return
}
emit('close')
}
function startEdit(bm) {
editingBookmark.value = bm
editForm.value = { name: bm.name, url: bm.url }
}
async function saveEdit() {
try {
await bookmarksApi.updateBookmark(editingBookmark.value.id, editForm.value)
editingBookmark.value = null
await loadBookmarks()
} catch (e) { console.error(e) }
}
async function deleteBookmark(bm) {
if (!confirm(`删除「${bm.name}」?`)) return
try {
await bookmarksApi.deleteBookmark(bm.id)
await loadBookmarks()
} catch (e) { console.error(e) }
}
</script>

View File

@@ -0,0 +1,165 @@
<template>
<div class="h-full flex overflow-hidden">
<!-- 主内容区 -->
<div class="flex-1 flex flex-col min-w-0">
<!-- 当前浏览的网站 -->
<template v-if="currentSite">
<!-- 工具栏 -->
<div class="flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-800 shrink-0">
<button @click="refreshIframe" class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="刷新">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
<div class="flex-1 flex items-center bg-gray-800 rounded-lg px-3 py-1.5 border border-gray-700">
<svg class="w-3.5 h-3.5 text-gray-500 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>
<span class="text-sm text-gray-300 truncate">{{ currentSite.url }}</span>
</div>
<button @click="openInNewWindow" class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="在新窗口打开">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</button>
<button @click="closeSite" class="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-700 rounded transition-colors" title="关闭">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- iframe -->
<div class="flex-1 relative">
<div v-if="iframeLoading" class="absolute inset-0 flex items-center justify-center bg-gray-950 z-10">
<div class="text-center">
<div class="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p class="text-sm text-gray-400">加载中...</p>
</div>
</div>
<div v-if="loadError" class="absolute inset-0 flex items-center justify-center bg-gray-950 z-10">
<div class="text-center max-w-md">
<svg class="w-12 h-12 text-gray-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
<p class="text-gray-400 mb-2">该网站可能不支持内嵌显示</p>
<p class="text-xs text-gray-600 mb-4">部分网站设置了安全策略禁止在 iframe 中加载</p>
<button @click="openInNewWindow" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm rounded-lg transition-colors">在新窗口打开</button>
</div>
</div>
<iframe
ref="iframeRef"
:src="currentSite.url"
class="w-full h-full border-0"
:class="{ 'opacity-0': iframeLoading || loadError }"
@load="onIframeLoad"
@error="onIframeError"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
allow="clipboard-read; clipboard-write"
></iframe>
</div>
</template>
<!-- 空状态 -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 text-gray-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>
<p class="text-gray-500 mb-1">从右侧收藏中选择一个网站</p>
<p class="text-xs text-gray-600">或添加新的收藏网站</p>
</div>
</div>
</div>
<!-- 右侧收藏侧边栏 -->
<BrowserSidebar
:open-tabs="openTabs"
:current-tab-id="currentSite?.id?.toString()"
@open-site="openSite"
@switch-tab="switchTab"
@close-tab="closeTab"
@delete-bookmark="deleteBookmark"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { bookmarksApi } from '../api/modules'
import BrowserSidebar from './BrowserSidebar.vue'
const currentSite = ref(null)
const openTabs = ref([])
const iframeRef = ref(null)
const iframeLoading = ref(false)
const loadError = ref(false)
let loadTimer = null
function openSite(bm) {
// 如果已打开,直接切换
const existing = openTabs.value.find(t => t.id === bm.id)
if (!existing) {
openTabs.value.push({ id: bm.id, name: bm.name, url: bm.url })
}
currentSite.value = { id: bm.id, name: bm.name, url: bm.url }
startLoading()
}
function switchTab(tabId) {
const tab = openTabs.value.find(t => t.id.toString() === tabId.toString())
if (tab) {
currentSite.value = { ...tab }
startLoading()
}
}
function closeTab(tabId) {
const idx = openTabs.value.findIndex(t => t.id.toString() === tabId.toString())
if (idx !== -1) {
openTabs.value.splice(idx, 1)
if (currentSite.value?.id?.toString() === tabId.toString()) {
currentSite.value = openTabs.value.length > 0 ? { ...openTabs.value[openTabs.value.length - 1] } : null
if (currentSite.value) startLoading()
}
}
}
function closeSite() {
if (currentSite.value) {
closeTab(currentSite.value.id)
}
}
async function deleteBookmark(bm) {
try {
await bookmarksApi.deleteBookmark(bm.id)
closeTab(bm.id)
} catch (e) {
console.error(e)
}
}
function startLoading() {
iframeLoading.value = true
loadError.value = false
clearTimeout(loadTimer)
loadTimer = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
loadError.value = true
}
}, 8000)
}
function onIframeLoad() {
clearTimeout(loadTimer)
iframeLoading.value = false
}
function onIframeError() {
clearTimeout(loadTimer)
iframeLoading.value = false
loadError.value = true
}
function refreshIframe() {
if (iframeRef.value && currentSite.value) {
startLoading()
iframeRef.value.src = currentSite.value.url
}
}
function openInNewWindow() {
if (currentSite.value) {
window.open(currentSite.value.url, '_blank')
}
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<!-- 收缩状态窄条 -->
<div
v-if="!isOpen"
class="w-10 bg-gray-900 border-l border-gray-800 flex flex-col items-center py-3 gap-2 shrink-0"
>
<button
@click="isOpen = true"
class="w-8 h-8 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center justify-center text-gray-400 hover:text-white transition-colors"
title="打开网站收藏"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</button>
<!-- 已打开网站的小图标 -->
<div
v-for="tab in openTabs"
:key="tab.id"
@click="$emit('switch-tab', tab.id); isOpen = false"
class="w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-colors"
:class="currentTabId === tab.id ? 'bg-indigo-600 text-white' : 'bg-gray-800 hover:bg-gray-700 text-indigo-400'"
:title="tab.name"
>
<span class="text-xs font-bold">{{ tab.name.charAt(0).toUpperCase() }}</span>
</div>
</div>
<!-- 展开状态 -->
<div v-else class="w-56 bg-gray-900 border-l border-gray-800 flex flex-col shrink-0">
<!-- 顶栏 -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-gray-800 shrink-0">
<span class="text-xs font-medium text-gray-300">网站收藏</span>
<div class="flex items-center gap-1">
<button @click="showAddForm = !showAddForm" class="p-1 text-gray-500 hover:text-indigo-400 rounded transition-colors" title="添加">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</button>
<button @click="isOpen = false" class="p-1 text-gray-500 hover:text-gray-300 rounded transition-colors" title="收起">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7"/></svg>
</button>
</div>
</div>
<!-- 添加表单 -->
<div v-if="showAddForm" class="px-3 py-2 border-b border-gray-800 space-y-2 shrink-0">
<input v-model="newName" placeholder="名称" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<input v-model="newUrl" placeholder="https://..." class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" @keydown.enter="addBookmark" />
<button @click="addBookmark" :disabled="!newName || !newUrl" class="w-full py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white text-xs rounded transition-colors">添加收藏</button>
</div>
<!-- 已打开的网站标签 -->
<div v-if="openTabs.length > 0" class="px-2 pt-2 pb-1 shrink-0">
<p class="text-[10px] text-gray-600 px-1 mb-1">已打开</p>
<div class="space-y-0.5">
<div
v-for="tab in openTabs"
:key="tab.id"
@click="$emit('switch-tab', tab.id)"
class="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors group"
:class="currentTabId === tab.id ? 'bg-indigo-600/20 text-indigo-300' : 'text-gray-400 hover:bg-gray-800'"
>
<div class="w-4 h-4 rounded bg-gray-700 flex items-center justify-center shrink-0">
<span class="text-[9px] font-bold text-indigo-400">{{ tab.name.charAt(0).toUpperCase() }}</span>
</div>
<span class="text-xs truncate flex-1">{{ tab.name }}</span>
<button @click.stop="$emit('close-tab', tab.id)" class="p-0.5 opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 transition-all">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>
<!-- 分隔线 -->
<div v-if="openTabs.length > 0 && bookmarks.length > 0" class="mx-3 border-t border-gray-800 my-1"></div>
<!-- 收藏列表 -->
<div class="flex-1 overflow-y-auto px-2 py-1">
<p v-if="bookmarks.length > 0" class="text-[10px] text-gray-600 px-1 mb-1">收藏</p>
<div v-if="bookmarks.length === 0" class="text-center py-8">
<svg class="w-8 h-8 text-gray-700 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
<p class="text-xs text-gray-600">点击 + 添加网站</p>
</div>
<div v-else class="space-y-0.5">
<div
v-for="bm in bookmarks"
:key="bm.id"
@click="$emit('open-site', bm)"
class="group flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-800/60 cursor-pointer transition-colors"
>
<div class="w-6 h-6 rounded-lg bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0">
<span class="text-[10px] font-bold text-indigo-400">{{ bm.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-xs text-gray-300 truncate">{{ bm.name }}</div>
<div class="text-[10px] text-gray-600 truncate">{{ bm.url.replace(/^https?:\/\//, '') }}</div>
</div>
<button @click.stop="$emit('delete-bookmark', bm)" class="p-0.5 opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { bookmarksApi } from '../api/modules'
const props = defineProps({
openTabs: { type: Array, default: () => [] },
currentTabId: { type: String, default: null },
})
const emit = defineEmits(['open-site', 'switch-tab', 'close-tab', 'delete-bookmark'])
const isOpen = ref(false)
const showAddForm = ref(false)
const bookmarks = ref([])
const newName = ref('')
const newUrl = ref('')
onMounted(() => { loadBookmarks() })
async function loadBookmarks() {
try { bookmarks.value = (await bookmarksApi.getBookmarks()).data }
catch (e) { console.error(e) }
}
async function addBookmark() {
if (!newName.value || !newUrl.value) return
let url = newUrl.value
if (!/^https?:\/\//.test(url)) url = 'https://' + url
try {
await bookmarksApi.createBookmark({ name: newName.value, url })
newName.value = ''
newUrl.value = ''
showAddForm.value = false
await loadBookmarks()
} catch (e) { console.error(e) }
}
// 暴露给父组件
defineExpose({ loadBookmarks })
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div class="h-full flex flex-col bg-gray-950">
<!-- 工具栏 -->
<div class="flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-800 shrink-0">
<!-- 刷新 -->
<button @click="refreshIframe" class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="刷新">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
<!-- 地址栏 -->
<div class="flex-1 flex items-center bg-gray-800 rounded-lg px-3 py-1.5 border border-gray-700">
<svg class="w-3.5 h-3.5 text-gray-500 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>
<span class="text-sm text-gray-300 truncate">{{ url }}</span>
</div>
<!-- 在新窗口打开 -->
<button @click="openInNewWindow" class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="在新窗口打开">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</button>
<!-- 发送给需求助手 -->
<button @click="sendToRequirement" class="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs rounded-lg transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
发送给需求助手
</button>
</div>
<!-- iframe 区域 -->
<div class="flex-1 relative">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-gray-950 z-10">
<div class="text-center">
<div class="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p class="text-sm text-gray-400">加载中...</p>
</div>
</div>
<div v-if="loadError" class="absolute inset-0 flex items-center justify-center bg-gray-950 z-10">
<div class="text-center max-w-md">
<svg class="w-12 h-12 text-gray-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
<p class="text-gray-400 mb-2">该网站可能不支持内嵌显示</p>
<p class="text-xs text-gray-600 mb-4">部分网站设置了安全策略禁止在 iframe 中加载</p>
<button @click="openInNewWindow" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm rounded-lg transition-colors">
在新窗口打开
</button>
</div>
</div>
<iframe
ref="iframeRef"
:src="url"
class="w-full h-full border-0"
:class="{ 'opacity-0': loading || loadError }"
@load="onIframeLoad"
@error="onIframeError"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
allow="clipboard-read; clipboard-write"
></iframe>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useTabsStore } from '../stores/tabs'
const props = defineProps({
url: { type: String, required: true },
tabId: { type: String, required: true },
})
const tabsStore = useTabsStore()
const iframeRef = ref(null)
const loading = ref(true)
const loadError = ref(false)
function onIframeLoad() {
loading.value = false
}
function onIframeError() {
loading.value = false
loadError.value = true
}
function refreshIframe() {
if (iframeRef.value) {
loading.value = true
loadError.value = false
iframeRef.value.src = props.url
}
}
function openInNewWindow() {
window.open(props.url, '_blank')
}
function sendToRequirement() {
// 切换到需求助手并预填 URL
window.__prefillRequirement = `请分析这个网页的内容:${props.url}`
tabsStore.setActiveTab('requirement')
}
onMounted(() => {
// 设置超时检测(部分网站 iframe 不触发 error 事件)
setTimeout(() => {
if (loading.value) {
loading.value = false
loadError.value = true
}
}, 8000)
})
</script>

View File

@@ -0,0 +1,140 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-4xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<h1 class="text-xl font-bold text-white">草稿箱</h1>
<span class="text-sm text-gray-500">{{ total }} 篇草稿</span>
</div>
<router-link to="/post/new" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg transition-colors">
写文章
</router-link>
</div>
<!-- 加载中 -->
<div v-if="loading" class="text-center py-20 text-gray-500 text-sm">加载中...</div>
<!-- 空状态 -->
<div v-else-if="drafts.length === 0" class="text-center py-20">
<svg class="w-12 h-12 text-gray-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-gray-600 text-sm">暂无草稿</p>
<p class="text-gray-700 text-xs mt-1">写文章时保存的草稿会显示在这里</p>
</div>
<!-- 草稿列表 -->
<div v-else class="space-y-3">
<div
v-for="draft in drafts"
:key="draft.id"
class="bg-gray-900/60 border border-gray-800/60 rounded-xl p-5 hover:border-gray-700/60 transition-colors group"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0 cursor-pointer" @click="editDraft(draft)">
<h3 class="text-base font-medium text-gray-200 truncate group-hover:text-white transition-colors">
{{ draft.title || '无标题草稿' }}
</h3>
<p class="text-sm text-gray-500 mt-1.5 line-clamp-2">
{{ stripMarkdown(draft.content).slice(0, 150) || '暂无内容' }}
</p>
<div class="flex items-center gap-3 mt-3">
<span v-if="draft.category" class="text-[10px] bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded">{{ draft.category }}</span>
<span class="text-[10px] text-gray-600">{{ formatDate(draft.updated_at) }}</span>
<span class="text-[10px] text-gray-700">{{ (draft.content || '').replace(/\s/g, '').length }} </span>
</div>
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button @click="editDraft(draft)" class="px-3 py-1.5 text-xs text-indigo-400 hover:text-indigo-300 hover:bg-indigo-600/10 rounded-lg transition-colors">
编辑
</button>
<button @click="deleteDraft(draft)" class="px-3 py-1.5 text-xs text-red-400 hover:text-red-300 hover:bg-red-600/10 rounded-lg transition-colors">
删除
</button>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center gap-1 mt-6">
<button
v-for="p in totalPages"
:key="p"
@click="page = p; fetchDrafts()"
class="w-8 h-8 rounded text-xs transition-colors"
:class="page === p ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'"
>
{{ p }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { postsApi } from '../api/modules'
const router = useRouter()
const drafts = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
async function fetchDrafts() {
loading.value = true
try {
const res = await postsApi.getDrafts({ page: page.value, page_size: pageSize })
drafts.value = res.data.items
total.value = res.data.total
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function editDraft(draft) {
router.push(`/post/edit/${draft.id}`)
}
async function deleteDraft(draft) {
if (!confirm(`确定删除草稿「${draft.title || '无标题'}」?`)) return
try {
await postsApi.deletePost(draft.id)
drafts.value = drafts.value.filter(d => d.id !== draft.id)
total.value--
} catch (e) {
alert(e.response?.data?.detail || '删除失败')
}
}
function stripMarkdown(text) {
if (!text) return ''
return text
.replace(/#{1,6}\s/g, '')
.replace(/[*_~`]/g, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\n/g, ' ')
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
const now = new Date()
const diff = now - d
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
if (diff < 604800000) return Math.floor(diff / 86400000) + ' 天前'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
onMounted(() => fetchDrafts())
</script>

332
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,332 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-4xl mx-auto px-6 py-6">
<!-- 搜索栏 -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-md">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
v-model="searchQuery"
@keydown.enter="handleSearch"
placeholder="搜索文章..."
class="w-full pl-9 pr-8 py-2 bg-gray-900 border border-gray-800 rounded-lg text-gray-100 placeholder-gray-600 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/>
<button v-if="searchQuery" @click="clearSearch" class="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-gray-500 hover:text-gray-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<!-- 搜索结果提示 -->
<div v-if="isSearching" class="flex items-center gap-2 mb-4 text-sm">
<span class="text-gray-400">搜索<span class="text-indigo-400">{{ searchQuery }}</span>的结果</span>
<button @click="clearSearch" class="text-xs text-gray-500 hover:text-indigo-400 transition-colors">清除搜索</button>
</div>
<!-- 顶部分类标签导航 -->
<div class="flex items-center gap-1 mb-4 overflow-x-auto pb-1 scrollbar-hide">
<button
v-for="cat in categories"
:key="cat"
@click="selectCategory(cat)"
class="px-3.5 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors shrink-0"
:class="activeCategory === cat
? 'bg-indigo-600 text-white'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>{{ cat }}</button>
</div>
<!-- 二级筛选栏 -->
<div class="flex items-center justify-between mb-5 border-b border-gray-800 pb-3">
<div class="flex gap-4">
<button
v-for="tab in sortTabs"
:key="tab.key"
@click="selectSort(tab.key)"
class="text-sm transition-colors relative pb-1"
:class="activeSort === tab.key
? 'text-indigo-400 font-medium'
: 'text-gray-500 hover:text-gray-300'"
>
{{ tab.label }}
<span v-if="activeSort === tab.key" class="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-500 rounded-full"></span>
</button>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-600"> {{ total }} </span>
<router-link
to="/post/new"
class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors"
>+ 写经验</router-link>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="text-center py-16">
<div class="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
<p class="text-sm text-gray-600">加载中...</p>
</div>
<!-- 空状态 -->
<div v-else-if="posts.length === 0" class="text-center py-20">
<svg class="w-12 h-12 text-gray-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/></svg>
<p class="text-gray-500 mb-1">{{ isSearching ? '没有找到相关文章' : activeSort === 'feed' ? '关注一些人看看他们的动态吧' : '暂无内容' }}</p>
<router-link v-if="activeSort === 'feed' && !isSearching" to="/" class="text-sm text-indigo-400 hover:text-indigo-300">看看热门内容</router-link>
</div>
<!-- 帖子列表 -->
<div v-else class="space-y-px">
<div
v-for="post in posts"
:key="post.id"
@click="$router.push(`/post/${post.id}`)"
class="py-5 border-b border-gray-800/60 cursor-pointer hover:bg-gray-900/40 transition-colors -mx-2 px-2 rounded-lg"
>
<!-- 作者行 -->
<div class="flex items-center gap-2 mb-2.5">
<div
@click.stop="$router.push(`/user/${post.author?.id}`)"
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold text-indigo-400 cursor-pointer hover:ring-1 hover:ring-indigo-500 transition shrink-0 overflow-hidden"
>
<img v-if="post.author?.avatar" :src="post.author.avatar" class="w-full h-full object-cover" />
<span v-else>{{ post.author?.username?.charAt(0)?.toUpperCase() || '?' }}</span>
</div>
<span
@click.stop="$router.push(`/user/${post.author?.id}`)"
class="text-sm text-gray-300 hover:text-indigo-400 cursor-pointer font-medium"
>{{ post.author?.username || '匿名' }}</span>
<span class="text-xs text-gray-600">· {{ formatTime(post.created_at) }}</span>
</div>
<!-- 标题 + 内容 + 封面图 -->
<div class="flex gap-4">
<div class="flex-1 min-w-0">
<h3 class="text-[15px] font-semibold text-gray-100 mb-1.5 leading-snug">{{ post.title }}</h3>
<p class="text-sm text-gray-500 line-clamp-2 leading-relaxed">{{ stripContent(post.content) }}</p>
</div>
<!-- 封面图 -->
<div v-if="post.cover_image" class="shrink-0">
<img
:src="post.cover_image"
class="w-[120px] h-[80px] object-cover rounded-lg bg-gray-800"
loading="lazy"
@error="(e) => e.target.style.display = 'none'"
/>
</div>
</div>
<!-- 底部统计 + 标签 -->
<div class="flex items-center mt-3 gap-5">
<div class="flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1 hover:text-indigo-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/></svg>
{{ post.like_count || 0 }}
</span>
<span class="flex items-center gap-1 hover:text-indigo-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
{{ post.view_count || 0 }}
</span>
<span class="flex items-center gap-1 hover:text-indigo-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{{ post.comment_count || 0 }}
</span>
<span class="flex items-center gap-1 hover:text-indigo-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
{{ post.collect_count || 0 }}
</span>
</div>
<div class="flex items-center gap-1.5 ml-auto">
<span v-if="post.category" class="px-2 py-0.5 bg-gray-800/80 text-[11px] text-gray-500 rounded">{{ post.category }}</span>
<template v-if="parseTags(post.tags).length">
<span
v-for="tag in parseTags(post.tags).slice(0, 2)"
:key="tag"
class="px-2 py-0.5 bg-gray-800/80 text-[11px] text-gray-500 rounded"
>{{ tag }}</span>
</template>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-1 mt-8 pb-4">
<button
@click="goPage(page - 1)"
:disabled="page <= 1"
class="w-8 h-8 flex items-center justify-center rounded-lg text-sm transition-colors"
:class="page <= 1 ? 'text-gray-700 cursor-not-allowed' : 'text-gray-400 hover:bg-gray-800'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
<template v-for="p in visiblePages" :key="p">
<span v-if="p === '...'" class="w-8 h-8 flex items-center justify-center text-gray-600 text-sm">...</span>
<button
v-else
@click="goPage(p)"
class="w-8 h-8 flex items-center justify-center rounded-lg text-sm transition-colors"
:class="page === p ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'"
>{{ p }}</button>
</template>
<button
@click="goPage(page + 1)"
:disabled="page >= totalPages"
class="w-8 h-8 flex items-center justify-center rounded-lg text-sm transition-colors"
:class="page >= totalPages ? 'text-gray-700 cursor-not-allowed' : 'text-gray-400 hover:bg-gray-800'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { feedApi, searchApi, categoryApi } from '../api/modules'
const categories = ref(['全部'])
const sortTabs = [
{ key: 'hot', label: '热门' },
{ key: 'latest', label: '最新' },
{ key: 'feed', label: '关注' },
]
const activeCategory = ref('全部')
const activeSort = ref('hot')
const posts = ref([])
const loading = ref(true)
const page = ref(1)
const total = ref(0)
const pageSize = 20
const searchQuery = ref('')
const isSearching = ref(false)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const visiblePages = computed(() => {
const tp = totalPages.value
const cp = page.value
if (tp <= 7) return Array.from({ length: tp }, (_, i) => i + 1)
const pages = []
pages.push(1)
if (cp > 3) pages.push('...')
for (let i = Math.max(2, cp - 1); i <= Math.min(tp - 1, cp + 1); i++) {
pages.push(i)
}
if (cp < tp - 2) pages.push('...')
pages.push(tp)
return pages
})
onMounted(async () => {
await loadCategories()
loadPosts()
})
async function loadCategories() {
try {
const { data } = await categoryApi.getActiveCategories()
categories.value = ['全部', ...data]
} catch (e) {
console.error(e)
}
}
function selectCategory(cat) {
activeCategory.value = cat
page.value = 1
loadPosts()
}
function selectSort(sort) {
activeSort.value = sort
page.value = 1
loadPosts()
}
function goPage(p) {
if (p < 1 || p > totalPages.value) return
page.value = p
loadPosts()
// 滚动到顶部
document.querySelector('.h-full.overflow-y-auto')?.scrollTo({ top: 0, behavior: 'smooth' })
}
async function loadPosts() {
loading.value = true
try {
const params = { page: page.value, page_size: pageSize }
if (activeCategory.value !== '全部') {
params.category = activeCategory.value
}
let res
// 搜索模式
if (searchQuery.value.trim()) {
isSearching.value = true
res = await searchApi.search({ ...params, q: searchQuery.value.trim() })
posts.value = res.data.items || []
total.value = res.data.total || 0
} else {
isSearching.value = false
if (activeSort.value === 'feed') {
res = await feedApi.getFeed(params)
} else if (activeSort.value === 'hot') {
res = await feedApi.getHot(params)
} else {
res = await feedApi.getLatest(params)
}
const data = res.data
posts.value = data.items || []
total.value = data.total || 0
}
} catch (e) {
console.error(e)
posts.value = []
total.value = 0
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
loadPosts()
}
function clearSearch() {
searchQuery.value = ''
isSearching.value = false
page.value = 1
loadPosts()
}
function stripContent(html) {
if (!html) return ''
return html.replace(/!\[.*?\]\(.*?\)/g, '').replace(/<[^>]*>/g, '').replace(/[#*`\[\]()>_~]/g, '').trim().substring(0, 200)
}
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
const now = new Date()
const diff = (now - d) / 1000
if (diff < 60) return '刚刚'
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
if (diff < 604800) return Math.floor(diff / 86400) + '天前'
return d.toLocaleDateString()
}
function parseTags(tags) {
if (!tags) return []
try {
const parsed = typeof tags === 'string' ? JSON.parse(tags) : tags
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
</script>

View File

@@ -0,0 +1,391 @@
<template>
<div class="h-full bg-gray-950 text-gray-100 flex flex-col overflow-hidden">
<!-- 密码验证层 -->
<div v-if="!authenticated" class="flex-1 flex items-center justify-center">
<div class="w-80 bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4">
<div class="text-center">
<div class="w-12 h-12 mx-auto rounded-xl bg-indigo-600/20 flex items-center justify-center mb-3">
<svg class="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
</div>
<h2 class="text-lg font-bold text-white">团队知识库</h2>
<p class="text-xs text-gray-500 mt-1">请输入访问密码</p>
</div>
<div>
<input
v-model="password"
type="password"
placeholder="输入密码"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
@keyup.enter="handleAuth"
/>
<p v-if="authError" class="text-xs text-red-400 mt-1">{{ authError }}</p>
</div>
<button
@click="handleAuth"
:disabled="authLoading"
class="w-full py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>{{ authLoading ? '验证中...' : '进入知识库' }}</button>
</div>
</div>
<!-- 主界面 -->
<template v-else>
<!-- 顶部标题栏 -->
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between shrink-0">
<div>
<h1 class="text-lg font-bold text-white">团队知识库</h1>
<p class="text-xs text-gray-500 mt-0.5"> {{ totalItems }} 篇知识文档</p>
</div>
<button
@click="showAiPanel = !showAiPanel"
class="px-3 py-1.5 bg-indigo-600/20 text-indigo-400 text-xs font-medium rounded-lg hover:bg-indigo-600/30 transition-colors flex items-center gap-1.5"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
AI 问答
</button>
</div>
<div class="flex-1 flex overflow-hidden">
<!-- 左侧分类 -->
<aside class="w-44 border-r border-gray-800 py-3 px-2 shrink-0 overflow-y-auto">
<div
@click="selectedCategory = null; fetchItems()"
class="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs cursor-pointer transition-colors mb-0.5"
:class="selectedCategory === null ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>
<span>全部</span>
<span class="text-[10px] text-gray-600">{{ totalItems }}</span>
</div>
<div
v-for="cat in categories"
:key="cat.id"
@click="selectedCategory = cat.id; fetchItems()"
class="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs cursor-pointer transition-colors mb-0.5"
:class="selectedCategory === cat.id ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>
<span>{{ cat.name }}</span>
<span class="text-[10px] text-gray-600">{{ cat.count }}</span>
</div>
</aside>
<!-- 主内容区 -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- 搜索栏 -->
<div class="px-4 py-3 border-b border-gray-800/50 shrink-0">
<div class="relative">
<svg class="w-4 h-4 text-gray-500 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
v-model="searchKeyword"
placeholder="搜索知识库..."
class="w-full pl-9 pr-3 py-2 bg-gray-900/50 border border-gray-800 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500/50"
@keyup.enter="fetchItems()"
/>
</div>
</div>
<!-- 条目列表 -->
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-2">
<div v-if="loading" class="text-center text-gray-500 text-xs py-10">加载中...</div>
<div v-else-if="items.length === 0" class="text-center text-gray-600 text-xs py-10">暂无知识文档</div>
<div
v-for="item in items"
:key="item.id"
@click="openDetail(item)"
class="bg-gray-900/50 border border-gray-800/50 rounded-xl p-4 cursor-pointer hover:border-gray-700 transition-colors group"
>
<div class="flex items-start justify-between">
<h3 class="text-sm font-medium text-white group-hover:text-indigo-400 transition-colors">{{ item.title }}</h3>
<span v-if="item.category_name" class="text-[10px] px-1.5 py-0.5 bg-indigo-600/10 text-indigo-400/70 rounded shrink-0 ml-2">{{ item.category_name }}</span>
</div>
<p class="text-xs text-gray-500 mt-1.5 line-clamp-2">{{ item.summary }}</p>
<div class="flex items-center gap-3 mt-2 text-[10px] text-gray-600">
<span>{{ item.added_by_name }}</span>
<span>{{ formatDate(item.created_at) }}</span>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 py-4">
<button
@click="page > 1 && (page--, fetchItems())"
:disabled="page <= 1"
class="px-2.5 py-1 text-xs text-gray-400 bg-gray-900 border border-gray-800 rounded disabled:opacity-30"
>上一页</button>
<span class="text-xs text-gray-500">{{ page }} / {{ totalPages }}</span>
<button
@click="page < totalPages && (page++, fetchItems())"
:disabled="page >= totalPages"
class="px-2.5 py-1 text-xs text-gray-400 bg-gray-900 border border-gray-800 rounded disabled:opacity-30"
>下一页</button>
</div>
</div>
</div>
<!-- AI 问答面板 -->
<aside v-if="showAiPanel" class="w-96 border-l border-gray-800 flex flex-col shrink-0">
<div class="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
<h3 class="text-sm font-medium text-white">AI 知识问答</h3>
<button @click="showAiPanel = false" class="text-gray-500 hover:text-gray-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 对话区 -->
<div ref="chatArea" class="flex-1 overflow-y-auto px-4 py-3 space-y-3">
<div v-if="chatMessages.length === 0" class="text-center text-gray-600 text-xs py-10">
<p>基于知识库内容进行智能问答</p>
<p class="mt-1">输入问题开始对话</p>
</div>
<div v-for="(msg, i) in chatMessages" :key="i" :class="msg.role === 'user' ? 'flex justify-end' : ''">
<div
:class="msg.role === 'user'
? 'bg-indigo-600/20 text-indigo-100 rounded-2xl rounded-tr-sm px-3 py-2 max-w-[85%]'
: 'bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-sm px-3 py-2 max-w-[95%]'"
>
<div v-if="msg.role === 'assistant'" class="text-xs text-gray-300 prose prose-invert prose-sm max-w-none" v-html="renderMd(msg.content)"></div>
<div v-else class="text-xs">{{ msg.content }}</div>
</div>
</div>
<div v-if="aiLoading" class="flex items-center gap-2 text-xs text-gray-500">
<div class="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-pulse"></div>
AI 思考中...
</div>
</div>
<!-- 输入区 -->
<div class="px-4 py-3 border-t border-gray-800">
<div class="flex gap-2">
<input
v-model="aiQuestion"
placeholder="输入你的问题..."
class="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-xs text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
@keyup.enter="sendAiQuestion"
/>
<button
@click="sendAiQuestion"
:disabled="aiLoading || !aiQuestion.trim()"
class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors disabled:opacity-50 shrink-0"
>发送</button>
</div>
</div>
</aside>
</div>
<!-- 文档详情弹窗 -->
<div v-if="detailItem" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6" @click.self="detailItem = null">
<div class="bg-gray-900 border border-gray-800 rounded-2xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between shrink-0">
<div>
<h2 class="text-base font-bold text-white">{{ detailItem.title }}</h2>
<div class="flex items-center gap-2 mt-1 text-[10px] text-gray-500">
<span v-if="detailItem.category_name" class="px-1.5 py-0.5 bg-indigo-600/10 text-indigo-400/70 rounded">{{ detailItem.category_name }}</span>
<span v-if="detailItem.post_author">{{ detailItem.post_author.username }}</span>
<span v-if="detailItem.post_category">{{ detailItem.post_category }}</span>
</div>
</div>
<button @click="detailItem = null" class="text-gray-500 hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<div class="prose prose-invert prose-sm max-w-none" v-html="renderMd(detailItem.post_content || '')"></div>
<!-- 附件 -->
<div v-if="detailItem.attachments?.length" class="mt-6 pt-4 border-t border-gray-800">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3">附件 ({{ detailItem.attachments.length }})</h4>
<div class="space-y-2">
<a v-for="att in detailItem.attachments" :key="att.id" :href="att.url" target="_blank" rel="noopener"
class="flex items-center gap-3 px-3 py-2 bg-gray-800/50 border border-gray-800 rounded-lg hover:border-indigo-500/40 hover:bg-indigo-600/5 transition-colors group">
<svg class="w-4 h-4 text-gray-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="flex-1 text-xs text-gray-300 truncate group-hover:text-indigo-400">{{ att.filename }}</span>
<span class="text-[10px] text-gray-600 shrink-0">{{ formatSize(att.file_size) }}</span>
<svg class="w-3.5 h-3.5 text-gray-600 group-hover:text-indigo-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
</div>
</div>
</div>
<div class="px-6 py-3 border-t border-gray-800 flex justify-end shrink-0">
<router-link
v-if="detailItem.post_id"
:to="`/post/${detailItem.post_id}`"
class="text-xs text-indigo-400 hover:text-indigo-300"
>查看原文 &rarr;</router-link>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted, computed, nextTick } from 'vue'
import { kbApi } from '../api/modules'
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt({ html: true, linkify: true, breaks: true })
function renderMd(text) { return md.render(text || '') }
function formatSize(bytes) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// 认证
const authenticated = ref(false)
const password = ref('')
const authError = ref('')
const authLoading = ref(false)
// 数据
const categories = ref([])
const items = ref([])
const selectedCategory = ref(null)
const searchKeyword = ref('')
const page = ref(1)
const pageSize = 20
const totalItems = ref(0)
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize))
const loading = ref(false)
// 详情
const detailItem = ref(null)
// AI 面板
const showAiPanel = ref(false)
const aiQuestion = ref('')
const aiLoading = ref(false)
const chatMessages = ref([])
const chatArea = ref(null)
onMounted(async () => {
try {
const res = await kbApi.checkPassword()
if (!res.data.has_password) {
// 无密码直接获取token
const authRes = await kbApi.auth('')
sessionStorage.setItem('kb_token', authRes.data.token)
authenticated.value = true
loadData()
} else {
// 检查是否已有token
const t = sessionStorage.getItem('kb_token')
if (t) {
try {
await kbApi.getCategories()
authenticated.value = true
loadData()
} catch {
// token 过期或密码已变更清除旧token
sessionStorage.removeItem('kb_token')
}
}
}
} catch (e) { /* ignore */ }
})
async function handleAuth() {
authError.value = ''
authLoading.value = true
try {
const res = await kbApi.auth(password.value)
sessionStorage.setItem('kb_token', res.data.token)
authenticated.value = true
loadData()
} catch (e) {
authError.value = e.response?.data?.detail || '验证失败'
} finally {
authLoading.value = false
}
}
async function loadData() {
await Promise.all([fetchCategories(), fetchItems()])
}
async function fetchCategories() {
try {
const res = await kbApi.getCategories()
categories.value = res.data
} catch (e) { /* ignore */ }
}
async function fetchItems() {
loading.value = true
try {
const params = { page: page.value, size: pageSize }
if (selectedCategory.value) params.category_id = selectedCategory.value
if (searchKeyword.value.trim()) params.keyword = searchKeyword.value.trim()
const res = await kbApi.getItems(params)
items.value = res.data.items
totalItems.value = res.data.total
} catch (e) { items.value = [] }
finally { loading.value = false }
}
async function openDetail(item) {
try {
const res = await kbApi.getItem(item.id)
detailItem.value = res.data
} catch (e) { /* ignore */ }
}
function formatDate(str) {
if (!str) return ''
return new Date(str).toLocaleDateString('zh-CN')
}
async function sendAiQuestion() {
const q = aiQuestion.value.trim()
if (!q || aiLoading.value) return
chatMessages.value.push({ role: 'user', content: q })
aiQuestion.value = ''
aiLoading.value = true
await nextTick()
scrollChat()
try {
const token = localStorage.getItem('token')
const kbToken = sessionStorage.getItem('kb_token')
const resp = await fetch('/api/kb/ai-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Kb-Token': kbToken || '',
},
body: JSON.stringify({ question: q }),
})
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let aiContent = ''
chatMessages.value.push({ role: 'assistant', content: '' })
const aiIdx = chatMessages.value.length - 1
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
const lines = text.split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.done) break
aiContent += data.content
chatMessages.value[aiIdx].content = aiContent
await nextTick()
scrollChat()
} catch { /* ignore */ }
}
}
} catch (e) {
chatMessages.value.push({ role: 'assistant', content: '请求失败,请稍后重试。' })
} finally {
aiLoading.value = false
}
}
function scrollChat() {
if (chatArea.value) chatArea.value.scrollTop = chatArea.value.scrollHeight
}
</script>

View File

@@ -0,0 +1,183 @@
<template>
<div class="h-screen bg-gray-950 text-gray-100 flex overflow-hidden">
<!-- 侧边栏 -->
<aside
class="bg-gray-900 border-r border-gray-800 flex flex-col shrink-0 transition-all duration-200"
:class="collapsed ? 'w-14' : 'w-52'"
>
<!-- Logo -->
<div class="px-3 py-4 border-b border-gray-800 flex items-center gap-2.5 h-14">
<div class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center shrink-0">
<svg class="w-4.5 h-4.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
</div>
<span v-show="!collapsed" class="text-sm font-bold text-white whitespace-nowrap">极码 GeekCode</span>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 py-2 px-2 space-y-0.5 overflow-y-auto">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors group relative"
:class="isActive(item.path, item.exact) ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>
<div class="w-5 h-5 shrink-0 flex items-center justify-center" v-html="item.icon"></div>
<span v-show="!collapsed" class="whitespace-nowrap">{{ item.label }}</span>
<!-- 消息角标 -->
<span
v-if="item.path === '/notifications' && unreadCount > 0"
class="absolute right-2 top-1.5 min-w-[16px] h-4 px-1 bg-red-500 text-white text-[10px] rounded-full flex items-center justify-center"
:class="collapsed ? 'right-0.5 top-0.5' : ''"
>{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</router-link>
<!-- 外部链接 -->
<a
href="https://qoder.com/marketplace"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors text-gray-400 hover:text-gray-200 hover:bg-gray-800/60 group"
>
<div class="w-5 h-5 shrink-0 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z"/></svg>
</div>
<span v-show="!collapsed" class="whitespace-nowrap">Qoder 技能市场</span>
<svg v-show="!collapsed" class="w-3 h-3 ml-auto text-gray-600 group-hover:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
<!-- 分隔线 -->
<div class="my-2 mx-1 border-t border-gray-800"></div>
<!-- 管理员菜单 -->
<router-link
v-if="isAdmin"
to="/admin"
class="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors"
:class="isActive('/admin') ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>
<div class="w-5 h-5 shrink-0 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<span v-show="!collapsed">管理</span>
</router-link>
</nav>
<!-- 底部折叠按钮 + 用户 -->
<div class="border-t border-gray-800 px-2 py-2 space-y-1">
<!-- 用户 -->
<router-link
to="/profile"
class="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm text-gray-400 hover:text-gray-200 hover:bg-gray-800/60 transition-colors"
>
<div class="w-5 h-5 rounded-full bg-gray-700 flex items-center justify-center shrink-0 text-[10px] font-bold text-indigo-400 overflow-hidden">
<img v-if="userStore.user?.avatar" :src="userStore.user.avatar" class="w-full h-full object-cover" />
<span v-else>{{ userStore.user?.username?.charAt(0)?.toUpperCase() || 'U' }}</span>
</div>
<span v-show="!collapsed" class="truncate">{{ userStore.user?.username }}</span>
</router-link>
<!-- 折叠/退出 -->
<div class="flex items-center" :class="collapsed ? 'justify-center' : 'justify-between px-2.5'">
<button
@click="collapsed = !collapsed"
class="p-1.5 text-gray-500 hover:text-gray-300 rounded transition-colors"
:title="collapsed ? '展开' : '收起'"
>
<svg class="w-4 h-4 transition-transform" :class="collapsed ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7"/></svg>
</button>
<button
v-show="!collapsed"
@click="handleLogout"
class="p-1.5 text-gray-600 hover:text-red-400 rounded transition-colors"
title="退出登录"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
</button>
</div>
</div>
</aside>
<!-- 主内容 -->
<main class="flex-1 overflow-hidden">
<router-view />
</main>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '../stores/user'
import { notificationsApi } from '../api/modules'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const collapsed = ref(false)
const unreadCount = ref(0)
const isAdmin = computed(() => userStore.user?.is_admin === true)
const navItems = [
{
path: '/', label: '首页', exact: true,
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>',
},
{
path: '/tools', label: 'AI 工具库',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/></svg>',
},
{
path: '/post/new', label: '写文章',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>',
},
{
path: '/drafts', label: '草稿箱',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>',
},
{
path: '/notifications', label: '消息',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>',
},
{
path: '/browser', label: '浏览器',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>',
},
{
path: '/nav', label: '导航站',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>',
},
{
path: '/projects', label: '开源项目',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>',
},
{
path: '/kb', label: '知识库',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>',
},
]
function isActive(path, exact = false) {
if (exact) return route.path === path
return route.path.startsWith(path)
}
async function fetchUnread() {
try {
const res = await notificationsApi.getUnreadCount()
unreadCount.value = res.data.count
} catch (e) { /* ignore */ }
}
onMounted(() => {
fetchUnread()
// 每30秒轮询未读数
setInterval(fetchUnread, 30000)
})
function handleLogout() {
userStore.logout()
router.push('/login')
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div class="min-h-screen bg-gray-950 flex items-center justify-center">
<div class="w-full max-w-md p-8">
<h1 class="text-3xl font-bold text-center text-indigo-400 mb-2">极码 GeekCode</h1>
<p class="text-center text-gray-500 mb-8">需求理解 · 架构选型 · 经验沉淀</p>
<div class="bg-gray-900 rounded-xl p-6 border border-gray-800">
<!-- 切换登录/注册 -->
<div class="flex mb-6 bg-gray-800 rounded-lg p-1">
<button
@click="isLogin = true"
class="flex-1 py-2 text-sm rounded-md transition-colors"
:class="isLogin ? 'bg-indigo-600 text-white' : 'text-gray-400'"
>
登录
</button>
<button
@click="isLogin = false"
class="flex-1 py-2 text-sm rounded-md transition-colors"
:class="!isLogin ? 'bg-indigo-600 text-white' : 'text-gray-400'"
>
注册
</button>
</div>
<form @submit.prevent="handleSubmit" autocomplete="off">
<div class="mb-4">
<label class="block text-sm text-gray-400 mb-1">用户名</label>
<input
v-model="form.username"
type="text"
autocomplete="off"
placeholder="请输入用户名"
class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
required
/>
</div>
<!-- 注册时显示邮箱 -->
<div v-if="!isLogin" class="mb-4">
<label class="block text-sm text-gray-400 mb-1">邮箱</label>
<input
v-model="form.email"
type="email"
autocomplete="off"
placeholder="请输入邮箱"
class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
required
/>
</div>
<div class="mb-6">
<label class="block text-sm text-gray-400 mb-1">密码</label>
<input
v-model="form.password"
type="password"
autocomplete="new-password"
placeholder="请输入密码"
class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
required
/>
</div>
<p v-if="error" class="text-red-400 text-sm mb-4">{{ error }}</p>
<p v-if="successMsg" class="text-green-400 text-sm mb-4 bg-green-900/20 border border-green-800/40 rounded-lg px-4 py-3">{{ successMsg }}</p>
<button
type="submit"
:disabled="loading"
class="w-full py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{{ loading ? '处理中...' : (isLogin ? '登录' : '注册') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { authApi } from '../api/modules'
const router = useRouter()
const userStore = useUserStore()
const isLogin = ref(true)
const loading = ref(false)
const error = ref('')
const successMsg = ref('')
const form = ref({ username: '', email: '', password: '' })
async function handleSubmit() {
error.value = ''
successMsg.value = ''
loading.value = true
try {
if (isLogin.value) {
await userStore.login(form.value.username, form.value.password)
await router.push('/')
} else {
const res = await authApi.register(form.value)
successMsg.value = res.data.message || '注册成功,请等待管理员审核'
form.value = { username: '', email: '', password: '' }
}
} catch (e) {
console.error('操作失败:', e)
error.value = e.response?.data?.detail || '操作失败,请重试'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,348 @@
<template>
<div class="max-w-6xl mx-auto p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-100">AI 模型管理</h1>
<p class="text-gray-500 mt-1">管理各AI服务商的模型配置和API Key</p>
</div>
<div class="flex gap-3">
<button @click="initDefaults" class="px-4 py-2 border border-gray-600 text-gray-300 rounded-lg hover:bg-gray-800 text-sm">
初始化默认配置
</button>
<button @click="showAddModal = true" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm">
+ 添加模型
</button>
</div>
</div>
<!-- 任务类型卡片 -->
<div class="grid grid-cols-5 gap-4 mb-6">
<div v-for="(label, key) in taskTypes" :key="key"
class="bg-gray-900 rounded-lg border p-4 cursor-pointer transition-all"
:class="filterTask === key ? 'border-indigo-500 ring-2 ring-indigo-500/20' : 'border-gray-700 hover:border-gray-600'"
@click="filterTask = filterTask === key ? '' : key">
<div class="text-sm font-medium text-gray-200">{{ label }}</div>
<div class="text-xs text-gray-500 mt-1">{{ key }}</div>
<div class="mt-2">
<span v-if="getDefaultModel(key)" class="text-xs bg-green-900/50 text-green-400 px-2 py-0.5 rounded">
{{ getDefaultModel(key)?.model_name || getDefaultModel(key)?.model_id }}
</span>
<span v-else class="text-xs bg-gray-800 text-gray-500 px-2 py-0.5 rounded">未配置</span>
</div>
</div>
</div>
<!-- 模型列表 -->
<div class="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<table class="w-full">
<thead class="bg-gray-800">
<tr>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-400">服务商</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-400">模型</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-400">任务类型</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-400">API Key</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-400">状态</th>
<th class="text-right px-4 py-3 text-sm font-medium text-gray-400">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="model in filteredModels" :key="model.id" class="border-t border-gray-800 hover:bg-gray-800/50">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="providerColor(model.provider)"></span>
<span class="text-sm font-medium text-gray-200">{{ model.provider_name || model.provider }}</span>
</div>
</td>
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-200">{{ model.model_name || model.model_id }}</div>
<div class="text-xs text-gray-500">{{ model.model_id }}</div>
</td>
<td class="px-4 py-3">
<span v-if="model.task_type" class="text-xs bg-indigo-900/50 text-indigo-300 px-2 py-0.5 rounded">
{{ taskTypes[model.task_type] || model.task_type }}
</span>
<span v-else class="text-xs text-gray-500">-</span>
</td>
<td class="px-4 py-3">
<span class="text-xs font-mono text-gray-400">{{ model.api_key_masked || '未配置' }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span v-if="model.is_default" class="text-xs bg-green-900/50 text-green-400 px-2 py-0.5 rounded">默认</span>
<span v-if="model.web_search_enabled" class="text-xs bg-blue-900/50 text-blue-400 px-2 py-0.5 rounded">🌐 联网</span>
<span :class="model.is_enabled ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'"
class="text-xs px-2 py-0.5 rounded">
{{ model.is_enabled ? '启用' : '禁用' }}
</span>
</div>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="testModel(model)" :disabled="testingId === model.id"
class="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-50">
{{ testingId === model.id ? '测试中...' : '测试' }}
</button>
<button @click="editModel(model)" class="text-xs text-gray-400 hover:text-gray-200">编辑</button>
<button @click="deleteModel(model)" class="text-xs text-red-400 hover:text-red-300">删除</button>
</div>
</td>
</tr>
<tr v-if="filteredModels.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-gray-500">
暂无模型配置点击添加模型初始化默认配置开始
</td>
</tr>
</tbody>
</table>
</div>
<!-- 添加/编辑模型弹窗 -->
<div v-if="showAddModal || showEditModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div class="bg-gray-900 border border-gray-700 rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<h2 class="text-lg font-bold text-gray-100 mb-4">{{ showEditModal ? '编辑模型' : '添加模型' }}</h2>
<!-- 快速选择预置模型 -->
<div v-if="showAddModal && !formData.model_id" class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">从预置模型中选择</label>
<div v-for="preset in presets" :key="preset.provider" class="mb-3">
<div class="text-xs text-gray-400 mb-1 font-medium">{{ preset.name }}</div>
<div class="flex flex-wrap gap-2">
<button v-for="m in preset.models" :key="m.model_id"
@click="selectPreset(preset, m)"
class="px-3 py-1.5 text-xs border border-gray-600 text-gray-300 rounded-lg hover:border-indigo-400 hover:bg-indigo-500/20 transition">
{{ m.name }}
</button>
</div>
</div>
<div class="border-t border-gray-700 mt-4 pt-4 text-center">
<button @click="formData.model_id = 'custom'" class="text-sm text-indigo-400 hover:underline">
手动填写自定义模型
</button>
</div>
</div>
<!-- 表单 -->
<div v-if="formData.model_id" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-400 mb-1">服务商标识</label>
<input v-model="formData.provider" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100" placeholder="如 deepseek" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">服务商名称</label>
<input v-model="formData.provider_name" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100" placeholder="如 DeepSeek" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-400 mb-1">模型ID</label>
<input v-model="formData.model_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100" placeholder="如 deepseek-chat" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">模型名称</label>
<input v-model="formData.model_name" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100" placeholder="如 DeepSeek-V3" />
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">API Key</label>
<input v-model="formData.api_key" type="password" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100 font-mono"
:placeholder="showEditModal ? '留空则不修改' : '输入API Key'" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Base URL可选</label>
<input v-model="formData.base_url" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100"
placeholder="默认使用服务商官方地址" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">任务类型 <span class="text-red-400">*</span></label>
<select v-model="formData.task_type" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100">
<option value="">请选择任务类型</option>
<option v-for="(label, key) in taskTypes" :key="key" :value="key">{{ label }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">描述</label>
<input v-model="formData.description" class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100" placeholder="模型用途描述" />
</div>
<div class="flex items-center gap-6 text-gray-300">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="formData.is_enabled" class="rounded" /> 启用
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="formData.is_default" class="rounded" /> 设为该任务类型默认
</label>
<label v-if="formData.provider === 'ark'" class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="formData.web_search_enabled" class="rounded" />
<span class="text-blue-400">🌐 联网搜索</span>
</label>
</div>
<div v-if="formData.provider === 'ark' && formData.web_search_enabled" class="space-y-2">
<div class="flex items-center gap-3">
<label class="text-sm text-gray-400 shrink-0">搜索条数</label>
<input type="number" v-model.number="formData.web_search_count" min="1" max="50"
class="w-20 px-2 py-1 bg-gray-800 border border-gray-600 rounded-lg text-sm text-gray-100 text-center" />
<span class="text-xs text-gray-500">范围 1-50默认 5</span>
</div>
<p class="text-xs text-blue-400/70">
开启后使用该模型的 AI 功能将自动联网搜索获取最新信息
</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-700">
<button @click="closeModal" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200">取消</button>
<button v-if="formData.model_id" @click="saveModel" :disabled="saving"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
<!-- 提示 -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white"
:class="toast.type === 'success' ? 'bg-green-600' : toast.type === 'error' ? 'bg-red-600' : 'bg-blue-600'">
{{ toast.message }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { aiModelsApi } from '../api/modules'
const models = ref([])
const presets = ref([])
const taskTypes = ref({})
const filterTask = ref('')
const showAddModal = ref(false)
const showEditModal = ref(false)
const editingId = ref(null)
const testingId = ref(null)
const saving = ref(false)
const toast = ref(null)
const formData = ref({
provider: '', provider_name: '', model_id: '', model_name: '',
api_key: '', base_url: '', task_type: '', description: '',
is_enabled: true, is_default: false, web_search_enabled: false, web_search_count: 5,
})
const filteredModels = computed(() => {
if (!filterTask.value) return models.value
return models.value.filter(m => m.task_type === filterTask.value)
})
function getDefaultModel(taskType) {
return models.value.find(m => m.task_type === taskType && m.is_default && m.is_enabled)
|| models.value.find(m => m.task_type === taskType && m.is_enabled)
}
function providerColor(provider) {
const colors = { deepseek: 'bg-blue-500', openai: 'bg-green-500', anthropic: 'bg-orange-500', google: 'bg-red-500', ark: 'bg-cyan-500' }
return colors[provider] || 'bg-gray-500'
}
function selectPreset(preset, model) {
formData.value.provider = preset.provider
formData.value.provider_name = preset.name
formData.value.model_id = model.model_id
formData.value.model_name = model.name
formData.value.base_url = preset.default_base_url
formData.value.description = model.description
formData.value.task_type = '' // 不自动选,让用户手动选择任务类型
}
function editModel(model) {
editingId.value = model.id
formData.value = {
provider: model.provider,
provider_name: model.provider_name,
model_id: model.model_id,
model_name: model.model_name,
api_key: '',
base_url: model.base_url,
task_type: model.task_type,
description: model.description,
is_enabled: model.is_enabled,
is_default: model.is_default,
web_search_enabled: model.web_search_enabled || false,
web_search_count: model.web_search_count || 5,
}
showEditModal.value = true
}
function closeModal() {
showAddModal.value = false
showEditModal.value = false
editingId.value = null
formData.value = { provider: '', provider_name: '', model_id: '', model_name: '', api_key: '', base_url: '', task_type: '', description: '', is_enabled: true, is_default: false, web_search_enabled: false, web_search_count: 5 }
}
function showToast(message, type = 'success') {
toast.value = { message, type }
setTimeout(() => toast.value = null, 3000)
}
async function loadModels() {
try { models.value = (await aiModelsApi.getModels()).data } catch (e) { console.error(e) }
}
async function loadPresets() {
try {
const [p, t] = await Promise.all([aiModelsApi.getPresets(), aiModelsApi.getTaskTypes()])
presets.value = p.data
taskTypes.value = t.data
} catch (e) { console.error(e) }
}
async function saveModel() {
saving.value = true
try {
if (showEditModal.value) {
await aiModelsApi.updateModel(editingId.value, formData.value)
showToast('模型配置已更新')
} else {
await aiModelsApi.createModel(formData.value)
showToast('模型已添加')
}
closeModal()
await loadModels()
} catch (e) {
showToast(e.response?.data?.detail || '保存失败', 'error')
} finally { saving.value = false }
}
async function deleteModel(model) {
if (!confirm(`确定删除 ${model.model_name || model.model_id}`)) return
try {
await aiModelsApi.deleteModel(model.id)
showToast('已删除')
await loadModels()
} catch (e) { showToast('删除失败', 'error') }
}
async function testModel(model) {
testingId.value = model.id
try {
const res = await aiModelsApi.testConnection(model.id)
showToast(res.data.message, res.data.success ? 'success' : 'error')
} catch (e) { showToast('测试请求失败', 'error') }
finally { testingId.value = null }
}
async function initDefaults() {
try {
const res = await aiModelsApi.initDefaults()
showToast(res.data.message)
await loadModels()
} catch (e) { showToast('初始化失败', 'error') }
}
onMounted(() => {
loadModels()
loadPresets()
})
</script>

View File

@@ -0,0 +1,261 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-5xl mx-auto px-6 py-6">
<!-- 标题区 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-bold text-gray-100">导航站</h1>
<p class="text-xs text-gray-500 mt-1">精选开发者常用网站与工具</p>
</div>
<div class="flex items-center gap-3">
<!-- 我的提交 -->
<button v-if="mySubmissions.length > 0" @click="showMySubmissions = true" class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200 border border-gray-800 rounded-lg hover:border-gray-700 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
我的提交
</button>
<!-- 推荐网站 -->
<button @click="openSubmitModal" class="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
推荐网站
</button>
<!-- 搜索 -->
<div class="relative w-52">
<svg class="w-4 h-4 text-gray-600 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
v-model="searchQuery"
placeholder="搜索网站..."
class="w-full pl-9 pr-8 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
<button v-if="searchQuery" @click="searchQuery = ''" class="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-gray-600 hover:text-gray-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="flex justify-center py-20">
<div class="w-7 h-7 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- 空状态 -->
<div v-else-if="filteredNav.length === 0" class="text-center py-20">
<svg class="w-14 h-14 text-gray-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
<p class="text-sm text-gray-500">{{ searchQuery ? '没有找到匹配的网站' : '暂无导航内容' }}</p>
</div>
<!-- 分类区块 -->
<div v-else class="space-y-8">
<div v-for="cat in filteredNav" :key="cat.id">
<!-- 分类标题 -->
<div class="flex items-center gap-2 mb-3">
<img v-if="cat.icon" :src="cat.icon" class="w-5 h-5 rounded" @error="$event.target.style.display='none'" />
<h2 class="text-base font-bold text-gray-200">{{ cat.name }}</h2>
<span class="text-[11px] text-gray-600">{{ cat.links.length }} </span>
<div class="flex-1 border-t border-gray-800/60 ml-2"></div>
</div>
<!-- 链接卡片网格 -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<a
v-for="link in cat.links"
:key="link.id"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="group bg-gray-900 border border-gray-800 rounded-xl px-4 py-3.5 hover:border-indigo-600/40 hover:bg-gray-900/80 transition-all cursor-pointer block"
>
<div class="flex items-start gap-3">
<!-- 图标 -->
<div class="w-9 h-9 rounded-lg bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0 group-hover:border-indigo-600/30 transition-colors">
<img v-if="link.icon" :src="link.icon" class="w-5 h-5 rounded" @error="$event.target.style.display='none'" />
<span v-else class="text-sm font-bold text-indigo-400">{{ link.name.charAt(0).toUpperCase() }}</span>
</div>
<!-- 文字 -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-200 group-hover:text-indigo-400 transition-colors truncate">{{ link.name }}</div>
<div v-if="link.description" class="text-[11px] text-gray-500 mt-0.5 line-clamp-2">{{ link.description }}</div>
<div class="text-[10px] text-gray-700 mt-1 truncate">{{ getDomain(link.url) }}</div>
</div>
</div>
</a>
</div>
</div>
</div>
</div>
<!-- 提交弹窗 -->
<Teleport to="body">
<div v-if="showSubmitModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showSubmitModal = false">
<div class="w-full max-w-md bg-gray-900 border border-gray-800 rounded-xl p-6 mx-4">
<div class="flex items-center justify-between mb-5">
<h3 class="text-base font-bold text-gray-100">推荐好用的网站</h3>
<button @click="showSubmitModal = false" class="p-1 text-gray-500 hover:text-gray-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs text-gray-400 mb-1">网站名称 <span class="text-red-500">*</span></label>
<input v-model="submitForm.name" placeholder="如GitHub" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">网站地址 <span class="text-red-500">*</span></label>
<input v-model="submitForm.url" placeholder="https://..." class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">分类 <span class="text-red-500">*</span></label>
<select v-model="submitForm.category_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500">
<option :value="0" disabled>请选择分类</option>
<option v-for="cat in publicCategories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">简介</label>
<input v-model="submitForm.description" placeholder="一句话描述这个网站" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">图标地址</label>
<input v-model="submitForm.icon" placeholder="https://xxx.com/favicon.ico可留空" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
</div>
<p v-if="submitError" class="text-xs text-red-400">{{ submitError }}</p>
<p v-if="submitSuccess" class="text-xs text-green-400">{{ submitSuccess }}</p>
<div class="flex justify-end gap-2 pt-2">
<button @click="showSubmitModal = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200">取消</button>
<button @click="doSubmit" :disabled="submitting" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ submitting ? '提交中...' : '提交推荐' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- 我的提交记录弹窗 -->
<Teleport to="body">
<div v-if="showMySubmissions" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showMySubmissions = false">
<div class="w-full max-w-lg bg-gray-900 border border-gray-800 rounded-xl p-6 mx-4 max-h-[70vh] flex flex-col">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-gray-100">我的提交记录</h3>
<button @click="showMySubmissions = false" class="p-1 text-gray-500 hover:text-gray-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto space-y-2">
<div v-for="s in mySubmissions" :key="s.id" class="bg-gray-800/50 border border-gray-800 rounded-lg px-4 py-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-200 font-medium">{{ s.name }}</span>
<span class="text-[10px] px-2 py-0.5 rounded-full" :class="statusClass(s.status)">{{ statusText(s.status) }}</span>
</div>
<div class="text-[11px] text-gray-500 mt-1 truncate">{{ s.url }}</div>
<div v-if="s.reject_reason" class="text-[11px] text-red-400 mt-1">拒绝原因{{ s.reject_reason }}</div>
</div>
<div v-if="mySubmissions.length === 0" class="text-center py-8">
<p class="text-xs text-gray-600">还没有提交记录</p>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { navApi } from '../api/modules'
const navData = ref([])
const loading = ref(true)
const searchQuery = ref('')
// 提交相关
const showSubmitModal = ref(false)
const submitting = ref(false)
const submitError = ref('')
const submitSuccess = ref('')
const publicCategories = ref([])
const submitForm = ref({ name: '', url: '', category_id: 0, description: '', icon: '' })
// 我的提交
const showMySubmissions = ref(false)
const mySubmissions = ref([])
const filteredNav = computed(() => {
if (!searchQuery.value.trim()) return navData.value
const q = searchQuery.value.toLowerCase()
return navData.value
.map(cat => ({
...cat,
links: cat.links.filter(
l => l.name.toLowerCase().includes(q) || l.description?.toLowerCase().includes(q) || l.url.toLowerCase().includes(q)
),
}))
.filter(cat => cat.links.length > 0)
})
onMounted(async () => {
try {
const [navRes, subRes] = await Promise.all([
navApi.getPublicNav(),
navApi.getMySubmissions().catch(() => ({ data: [] })),
])
navData.value = navRes.data
mySubmissions.value = subRes.data
} catch (e) { console.error(e) }
finally { loading.value = false }
})
async function openSubmitModal() {
submitError.value = ''
submitSuccess.value = ''
submitForm.value = { name: '', url: '', category_id: 0, description: '', icon: '' }
showSubmitModal.value = true
if (publicCategories.value.length === 0) {
try {
const { data } = await navApi.getPublicCategories()
publicCategories.value = data
} catch (e) { console.error(e) }
}
}
async function doSubmit() {
const f = submitForm.value
if (!f.name.trim() || !f.url.trim() || !f.category_id) {
submitError.value = '请填写网站名称、地址并选择分类'
return
}
submitting.value = true
submitError.value = ''
submitSuccess.value = ''
try {
let url = f.url.trim()
if (!/^https?:\/\//.test(url)) url = 'https://' + url
await navApi.submitLink({ ...f, url })
submitSuccess.value = '提交成功!等待管理员审核'
// 刷新我的提交
const { data } = await navApi.getMySubmissions()
mySubmissions.value = data
setTimeout(() => { showSubmitModal.value = false }, 1500)
} catch (e) {
submitError.value = e.response?.data?.detail || '提交失败'
} finally { submitting.value = false }
}
function statusText(s) {
return s === 'approved' ? '已通过' : s === 'pending' ? '待审核' : '已拒绝'
}
function statusClass(s) {
return s === 'approved' ? 'bg-green-900/40 text-green-400' : s === 'pending' ? 'bg-yellow-900/40 text-yellow-400' : 'bg-red-900/40 text-red-400'
}
function getDomain(url) {
try {
return new URL(url).hostname
} catch {
return url.replace(/^https?:\/\//, '').split('/')[0]
}
}
</script>

View File

@@ -0,0 +1,203 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-2xl mx-auto px-6 py-8">
<!-- 头部 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-white">消息通知</h1>
<button
v-if="notifications.length > 0 && hasUnread"
@click="markAllRead"
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
全部标为已读
</button>
</div>
<!-- 筛选标签 -->
<div class="flex gap-2 mb-6">
<button
v-for="tab in tabs"
:key="tab.value"
@click="activeTab = tab.value"
class="px-3 py-1.5 text-xs rounded-lg transition-colors"
:class="activeTab === tab.value ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-gray-200 hover:bg-gray-700'"
>
{{ tab.label }}
</button>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-16">
<div class="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- 空状态 -->
<div v-else-if="filteredNotifications.length === 0" class="text-center py-16">
<svg class="w-12 h-12 text-gray-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
<p class="text-gray-500 text-sm">暂无通知</p>
</div>
<!-- 通知列表 -->
<div v-else class="space-y-1">
<div
v-for="notif in filteredNotifications"
:key="notif.id"
@click="handleClick(notif)"
class="flex items-start gap-3 px-4 py-3 rounded-lg cursor-pointer transition-colors"
:class="notif.is_read ? 'hover:bg-gray-800/40' : 'bg-gray-800/60 hover:bg-gray-800'"
>
<!-- 未读点 -->
<div class="pt-1.5 shrink-0">
<div
class="w-2 h-2 rounded-full"
:class="notif.is_read ? 'bg-transparent' : 'bg-indigo-500'"
></div>
</div>
<!-- 图标 -->
<div
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5"
:class="iconClass(notif.type)"
>
<svg v-if="notif.type === 'like'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<svg v-else-if="notif.type === 'comment'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<svg v-else-if="notif.type === 'follow'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<!-- 内容 -->
<div class="flex-1 min-w-0">
<p class="text-sm" :class="notif.is_read ? 'text-gray-400' : 'text-gray-200'">
{{ notif.content }}
</p>
<p class="text-xs text-gray-600 mt-1">{{ formatTime(notif.created_at) }}</p>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore && !loading" class="text-center py-6">
<button
@click="loadMore"
class="px-4 py-2 text-xs text-gray-400 hover:text-gray-200 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
>
加载更多
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { notificationsApi } from '../api/modules'
const router = useRouter()
const notifications = ref([])
const loading = ref(true)
const page = ref(1)
const hasMore = ref(false)
const activeTab = ref('all')
const tabs = [
{ label: '全部', value: 'all' },
{ label: '点赞', value: 'like' },
{ label: '评论', value: 'comment' },
{ label: '关注', value: 'follow' },
{ label: '系统', value: 'system' },
]
const filteredNotifications = computed(() => {
if (activeTab.value === 'all') return notifications.value
return notifications.value.filter(n => n.type === activeTab.value)
})
const hasUnread = computed(() => notifications.value.some(n => !n.is_read))
function iconClass(type) {
const map = {
like: 'bg-pink-500/15 text-pink-400',
comment: 'bg-blue-500/15 text-blue-400',
follow: 'bg-green-500/15 text-green-400',
system: 'bg-yellow-500/15 text-yellow-400',
}
return map[type] || map.system
}
function formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
const now = new Date()
const diff = (now - d) / 1000
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
return d.toLocaleDateString('zh-CN')
}
async function fetchNotifications(pageNum = 1) {
loading.value = true
try {
const res = await notificationsApi.getNotifications({ page: pageNum, page_size: 20 })
const data = res.data
if (Array.isArray(data)) {
if (pageNum === 1) {
notifications.value = data
} else {
notifications.value.push(...data)
}
hasMore.value = data.length === 20
} else if (data.items) {
if (pageNum === 1) {
notifications.value = data.items
} else {
notifications.value.push(...data.items)
}
hasMore.value = data.items.length === 20
}
} catch (e) {
console.error('Failed to fetch notifications:', e)
} finally {
loading.value = false
}
}
function loadMore() {
page.value++
fetchNotifications(page.value)
}
async function markAllRead() {
try {
await notificationsApi.readAll()
notifications.value.forEach(n => n.is_read = true)
} catch (e) {
console.error(e)
}
}
async function handleClick(notif) {
// 标记已读
if (!notif.is_read) {
try {
await notificationsApi.readOne(notif.id)
notif.is_read = true
} catch (e) { /* ignore */ }
}
// 跳转
if (notif.type === 'follow' && notif.from_user_id) {
router.push(`/user/${notif.from_user_id}`)
} else if ((notif.type === 'like' || notif.type === 'comment') && notif.related_id) {
router.push(`/post/${notif.related_id}`)
}
}
onMounted(() => {
fetchNotifications()
})
</script>

View File

@@ -0,0 +1,232 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-4xl mx-auto p-6">
<div v-if="post" class="bg-gray-900 border border-gray-800 rounded-xl p-6">
<!-- 标题 -->
<h1 class="text-xl font-bold text-gray-100 mb-3">{{ post.title }}</h1>
<div class="flex items-center gap-4 text-xs text-gray-500 mb-6">
<span>{{ post.author_name }}</span>
<span>{{ formatDate(post.created_at) }}</span>
<span v-if="post.category" class="bg-gray-800 px-2 py-0.5 rounded">{{ post.category }}</span>
<span>{{ post.view_count }} 浏览</span>
</div>
<!-- 内容 -->
<div class="markdown-body text-sm text-gray-200 mb-6" v-html="renderMarkdown(post.content)"></div>
<!-- 附件 -->
<div v-if="attachments.length" class="mb-6">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3">附件 ({{ attachments.length }})</h4>
<div class="space-y-2">
<a
v-for="att in attachments"
:key="att.id"
:href="att.url"
target="_blank"
rel="noopener"
class="flex items-center gap-3 px-4 py-2.5 bg-gray-800/50 border border-gray-800 rounded-lg hover:border-indigo-500/40 hover:bg-indigo-600/5 transition-colors group"
>
<svg class="w-5 h-5 shrink-0" :class="attachmentIconColor(att.file_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="attachmentIconPath(att.file_type)"/></svg>
<span class="flex-1 text-sm text-gray-300 truncate group-hover:text-indigo-400">{{ att.filename }}</span>
<span class="text-xs text-gray-600 shrink-0">{{ formatFileSize(att.file_size) }}</span>
<svg class="w-4 h-4 text-gray-600 group-hover:text-indigo-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
</div>
</div>
<!-- 标签 -->
<div v-if="parseTags(post.tags).length" class="flex gap-2 mb-6">
<span
v-for="tag in parseTags(post.tags)"
:key="tag"
class="bg-gray-800 text-gray-400 px-2 py-0.5 rounded text-xs"
>
{{ tag }}
</span>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-4 border-t border-gray-800 pt-4">
<button
@click="handleLike"
class="flex items-center gap-1 text-sm transition-colors"
:class="post.is_liked ? 'text-red-400' : 'text-gray-500 hover:text-red-400'"
>
{{ post.is_liked ? '已赞' : '点赞' }} {{ post.like_count }}
</button>
<button
@click="handleCollect"
class="flex items-center gap-1 text-sm transition-colors"
:class="post.is_collected ? 'text-yellow-400' : 'text-gray-500 hover:text-yellow-400'"
>
{{ post.is_collected ? '已收藏' : '收藏' }} {{ post.collect_count }}
</button>
<router-link
v-if="isAuthor"
:to="`/post/edit/${post.id}`"
class="text-sm text-gray-500 hover:text-indigo-400 transition-colors"
>
编辑
</router-link>
<button
v-if="isAuthor"
@click="handleDelete"
class="text-sm text-gray-500 hover:text-red-400 transition-colors"
>
删除
</button>
<button
@click="$router.back()"
class="ml-auto text-sm text-gray-500 hover:text-gray-300 transition-colors"
>
返回
</button>
</div>
</div>
<!-- 评论区 -->
<div class="mt-6">
<h3 class="text-base font-medium text-gray-300 mb-4">评论 ({{ comments.length }})</h3>
<!-- 发评论 -->
<div class="flex gap-3 mb-6">
<input
v-model="commentText"
@keydown.enter="handleComment"
placeholder="写下你的评论..."
class="flex-1 px-4 py-2 bg-gray-900 border border-gray-800 rounded-lg text-gray-100 text-sm placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
<button
@click="handleComment"
:disabled="!commentText.trim()"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-30 text-white rounded-lg text-sm transition-colors"
>
评论
</button>
</div>
<!-- 评论列表 -->
<div class="space-y-3">
<div
v-for="c in comments"
:key="c.id"
class="bg-gray-900 border border-gray-800 rounded-lg p-4"
>
<div class="flex items-center gap-3 mb-2 text-xs text-gray-500">
<span class="text-gray-300">{{ c.author_name }}</span>
<span>{{ formatDate(c.created_at) }}</span>
</div>
<p class="text-sm text-gray-300">{{ c.content }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MarkdownIt from 'markdown-it'
import { postsApi } from '../api/modules'
import { useUserStore } from '../stores/user'
const md = new MarkdownIt({ html: true, breaks: true, linkify: true })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const post = ref(null)
const comments = ref([])
const commentText = ref('')
const attachments = ref([])
const isAuthor = computed(() => post.value && userStore.user && post.value.user_id === userStore.user.id)
onMounted(async () => {
const id = route.params.id
try {
const [postRes, commentsRes] = await Promise.all([
postsApi.getPost(id),
postsApi.getComments(id),
])
post.value = postRes.data
comments.value = commentsRes.data
} catch (e) {
console.error(e)
}
// 加载附件
try {
const { data } = await postsApi.getAttachments(id)
attachments.value = data
} catch (e) { /* ignore */ }
})
function renderMarkdown(text) {
return text ? md.render(text) : ''
}
function parseTags(tagsStr) {
try { return JSON.parse(tagsStr) } catch { return [] }
}
function formatDate(dateStr) {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function handleLike() {
try {
const res = await postsApi.toggleLike(post.value.id)
post.value.is_liked = res.data.liked
post.value.like_count = res.data.like_count
} catch (e) { console.error(e) }
}
async function handleCollect() {
try {
const res = await postsApi.toggleCollect(post.value.id)
post.value.is_collected = res.data.collected
post.value.collect_count = res.data.collect_count
} catch (e) { console.error(e) }
}
async function handleComment() {
if (!commentText.value.trim()) return
try {
const res = await postsApi.createComment(post.value.id, { content: commentText.value.trim() })
comments.value.push(res.data)
commentText.value = ''
} catch (e) { console.error(e) }
}
async function handleDelete() {
if (!confirm('确定删除这篇经验记录吗?')) return
try {
await postsApi.deletePost(post.value.id)
router.push('/knowledge')
} catch (e) { console.error(e) }
}
function formatFileSize(bytes) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function attachmentIconPath(fileType) {
if (fileType?.includes('pdf')) return 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'
if (fileType?.includes('zip') || fileType?.includes('rar')) return 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4'
return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
}
function attachmentIconColor(fileType) {
if (fileType?.includes('pdf')) return 'text-red-400/70'
if (fileType?.includes('word') || fileType?.includes('document')) return 'text-blue-400/70'
if (fileType?.includes('sheet') || fileType?.includes('excel')) return 'text-green-400/70'
if (fileType?.includes('presentation') || fileType?.includes('powerpoint')) return 'text-orange-400/70'
if (fileType?.includes('zip') || fileType?.includes('rar')) return 'text-yellow-400/70'
return 'text-gray-400/70'
}
</script>

View File

@@ -0,0 +1,947 @@
<template>
<div class="h-full flex flex-col bg-gray-950">
<!-- 顶部操作栏 -->
<div class="shrink-0 flex items-center justify-between px-6 py-3 border-b border-gray-800/60 bg-gray-950/80 backdrop-blur-sm">
<div class="flex items-center gap-3">
<button @click="$router.back()" class="p-1.5 text-gray-500 hover:text-gray-300 hover:bg-gray-800 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</button>
<span class="text-sm text-gray-400">{{ isEdit ? '编辑文章' : '写文章' }}</span>
<span v-if="isDraft" class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">草稿</span>
<span v-if="autoSaveStatus" class="text-xs flex items-center gap-1" :class="autoSaveStatus === 'saving' ? 'text-yellow-500' : autoSaveStatus === 'saved' ? 'text-gray-600' : 'text-red-400'">
<svg v-if="autoSaveStatus === 'saved'" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span v-if="autoSaveStatus === 'saving'" class="w-3 h-3 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin"></span>
<svg v-if="autoSaveStatus === 'failed'" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ autoSaveStatus === 'saving' ? '保存中...' : autoSaveStatus === 'saved' ? '已自动保存' : '保存失败已离线缓存' }}
</span>
</div>
<div class="flex items-center gap-2">
<button @click="editorMode = editorMode === 'edit' ? 'preview' : 'edit'" class="px-3 py-1.5 text-xs rounded-lg transition-colors" :class="editorMode === 'preview' ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'">
{{ editorMode === 'preview' ? '编辑' : '预览' }}
</button>
<button @click="editorMode = 'split'" class="px-3 py-1.5 text-xs rounded-lg transition-colors" :class="editorMode === 'split' ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'">
分屏
</button>
<div class="w-px h-5 bg-gray-800 mx-1"></div>
<button
@click="saveDraft"
:disabled="savingDraft"
class="px-4 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-30 disabled:cursor-not-allowed text-gray-200 text-sm rounded-lg transition-colors"
>
{{ savingDraft ? '保存中...' : '保存草稿' }}
</button>
<button
@click="handleSubmit"
:disabled="!form.title.trim() || !form.content.trim() || submitting"
class="px-5 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-30 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors font-medium"
>
{{ submitting ? '发布中...' : (isEdit ? '更新' : '发布') }}
</button>
</div>
</div>
<!-- 主体区域左编辑器 + 右设置面板 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧编辑区 -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- 标题输入 -->
<div class="px-12 pt-8 pb-2">
<input
v-model="form.title"
placeholder="输入文章标题..."
class="w-full text-2xl font-bold bg-transparent text-gray-100 placeholder-gray-700 focus:outline-none border-none leading-tight"
/>
</div>
<!-- 编辑器工具栏 -->
<div class="px-12 py-2 flex items-center gap-0.5">
<template v-for="btn in toolbarBtns" :key="btn.tip">
<div class="relative group">
<component :is="btn.tag || 'button'" @click="btn.action?.()" :class="'w-7 h-7 flex items-center justify-center rounded text-gray-500 hover:text-indigo-400 hover:bg-gray-800/80 transition-colors' + (btn.tag === 'label' ? ' cursor-pointer' : '')">
<span v-if="btn.text" v-html="btn.text"></span>
<svg v-if="btn.icon" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="btn.icon"/></svg>
<input v-if="btn.tag === 'label'" type="file" accept="image/*" class="hidden" @change="handleImageUpload" multiple />
</component>
<span class="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-gray-300 text-[10px] rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">{{ btn.tip }}</span>
</div>
<div v-if="btn.divider" class="w-px h-4 bg-gray-800 mx-1"></div>
</template>
<span v-if="uploading" class="text-xs text-indigo-400 ml-2 flex items-center gap-1.5">
<span class="w-3 h-3 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></span>
上传中...
</span>
<span class="flex-1"></span>
<button
@click="aiFormat"
:disabled="formatting || !form.content.trim()"
class="flex items-center gap-1.5 px-3 py-1 text-[11px] rounded transition-colors mr-2"
:class="formatting ? 'bg-indigo-600/20 text-indigo-400' : 'text-indigo-400 hover:bg-indigo-600/10 hover:text-indigo-300'"
>
<span v-if="formatting" class="w-3 h-3 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></span>
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"/></svg>
{{ formatting ? 'AI 排版中...' : 'AI 智能排版' }}
</button>
<button @click="showMdHelp = true" class="flex items-center gap-1 px-2 py-1 text-[11px] text-gray-600 hover:text-indigo-400 hover:bg-gray-800/60 rounded transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Markdown 语法帮助
</button>
</div>
<!-- 编辑/预览区域 -->
<div class="flex-1 overflow-hidden px-12 pb-6">
<!-- 纯编辑模式 -->
<textarea
v-if="editorMode === 'edit'"
ref="editorRef"
v-model="form.content"
placeholder="开始书写你的文章内容...\n\n支持 Markdown 语法,可直接粘贴或拖拽图片"
class="w-full h-full bg-transparent text-gray-200 text-[15px] leading-7 placeholder-gray-700 focus:outline-none resize-none font-mono"
@paste="handlePaste"
@drop.prevent="handleDrop"
@dragover.prevent
@keydown="handleEditorKeydown"
@input="handleEditorInput"
></textarea>
<!-- 纯预览模式 -->
<div v-else-if="editorMode === 'preview'" class="h-full overflow-y-auto">
<div class="markdown-body text-[15px] text-gray-200 leading-7" v-html="preview"></div>
<p v-if="!form.content" class="text-gray-700 text-sm">暂无内容</p>
</div>
<!-- 分屏模式 -->
<div v-else class="h-full flex gap-4">
<textarea
ref="editorRef"
v-model="form.content"
placeholder="开始书写..."
class="flex-1 bg-gray-900/40 border border-gray-800/60 rounded-lg px-5 py-4 text-gray-200 text-sm leading-7 placeholder-gray-700 focus:outline-none focus:border-gray-700 resize-none font-mono"
@paste="handlePaste"
@drop.prevent="handleDrop"
@dragover.prevent
@keydown="handleEditorKeydown"
@input="handleEditorInput"
></textarea>
<div class="flex-1 bg-gray-900/40 border border-gray-800/60 rounded-lg px-5 py-4 overflow-y-auto">
<div class="markdown-body text-sm text-gray-200 leading-7" v-html="preview"></div>
<p v-if="!form.content" class="text-gray-700 text-sm">预览区域</p>
</div>
</div>
</div>
</div>
<!-- 右侧设置面板 -->
<div class="w-72 shrink-0 border-l border-gray-800/60 overflow-y-auto bg-gray-900/30">
<div class="p-5 space-y-5">
<!-- 发布状态 -->
<div class="space-y-3">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider">发布设置</h4>
<label class="flex items-center justify-between py-2 px-3 bg-gray-900/60 rounded-lg cursor-pointer group">
<span class="text-sm text-gray-300 group-hover:text-gray-200">公开发布</span>
<div class="relative">
<input type="checkbox" v-model="form.is_public" class="sr-only peer" />
<div class="w-9 h-5 bg-gray-700 rounded-full peer-checked:bg-indigo-600 transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</div>
</label>
<p class="text-[11px] text-gray-600 px-1">{{ form.is_public ? '所有人可见' : '仅自己可见' }}</p>
</div>
<!-- 分类 -->
<div class="space-y-2">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider">分类</h4>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cat in categories"
:key="cat"
@click="form.category = form.category === cat ? '' : cat"
class="px-2.5 py-1 rounded-md text-xs transition-colors"
:class="form.category === cat
? 'bg-indigo-600 text-white'
: 'bg-gray-800/80 text-gray-400 hover:text-gray-200 hover:bg-gray-800'"
>{{ cat }}</button>
</div>
</div>
<!-- 标签 -->
<div class="space-y-2">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider">标签</h4>
<div class="flex items-center gap-1">
<input
v-model="tagInput"
@keydown.enter.prevent="addTag"
placeholder="输入后回车"
class="flex-1 px-3 py-1.5 bg-gray-900/60 border border-gray-800 rounded-lg text-gray-200 text-xs placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
<button @click="addTag" class="px-2 py-1.5 text-xs text-gray-500 hover:text-indigo-400 transition-colors">+</button>
</div>
<div v-if="form.tags.length" class="flex flex-wrap gap-1.5">
<span
v-for="(tag, i) in form.tags"
:key="i"
class="inline-flex items-center gap-1 bg-indigo-600/15 text-indigo-400 pl-2 pr-1 py-0.5 rounded text-xs"
>
{{ tag }}
<button @click="form.tags.splice(i, 1)" class="p-0.5 hover:text-white rounded hover:bg-indigo-600/30 transition-colors">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</span>
</div>
</div>
<!-- 附件 -->
<div class="space-y-2">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider">附件</h4>
<label class="flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-900/60 border border-dashed border-gray-700 rounded-lg cursor-pointer hover:border-indigo-500/50 hover:bg-indigo-600/5 transition-colors">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
<span class="text-xs text-gray-400">{{ uploadingAttachment ? '上传中...' : '上传附件' }}</span>
<input type="file" class="hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar" @change="handleAttachmentUpload" multiple :disabled="uploadingAttachment" />
</label>
<p class="text-[10px] text-gray-600 px-1">支持 PDF/Word/Excel/PPT/ZIP/RAR单文件最大 20MB</p>
<div v-if="attachments.length" class="space-y-1.5">
<div v-for="att in attachments" :key="att.id" class="flex items-center gap-2 px-3 py-2 bg-gray-900/60 rounded-lg group">
<svg class="w-4 h-4 text-gray-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-300 truncate">{{ att.filename }}</p>
<p class="text-[10px] text-gray-600">{{ formatFileSize(att.file_size) }}</p>
</div>
<button @click="removeAttachment(att.id)" class="p-0.5 text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all" title="删除">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>
<!-- 模板 -->
<div v-if="!isEdit" class="space-y-2">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider">快速模板</h4>
<div class="space-y-1">
<button
v-for="tpl in templates"
:key="tpl.name"
@click="applyTemplate(tpl)"
class="w-full text-left px-3 py-2 rounded-lg text-xs text-gray-400 hover:text-gray-200 hover:bg-gray-800/80 transition-colors flex items-center gap-2"
>
<svg class="w-3.5 h-3.5 text-gray-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
{{ tpl.name }}
</button>
</div>
</div>
<!-- 字数统计 -->
<div class="space-y-2">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider">统计</h4>
<div class="grid grid-cols-2 gap-2">
<div class="px-3 py-2 bg-gray-900/60 rounded-lg">
<p class="text-lg font-semibold text-gray-200">{{ wordCount }}</p>
<p class="text-[10px] text-gray-600">字数</p>
</div>
<div class="px-3 py-2 bg-gray-900/60 rounded-lg">
<p class="text-lg font-semibold text-gray-200">{{ readTime }}</p>
<p class="text-[10px] text-gray-600">分钟阅读</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Markdown 语法帮助弹窗 -->
<Teleport to="body">
<div v-if="showMdHelp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showMdHelp = false">
<div class="w-full max-w-3xl max-h-[85vh] bg-gray-900 border border-gray-800 rounded-xl mx-4 flex flex-col">
<!-- 头部 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-800 shrink-0">
<h3 class="text-lg font-bold text-gray-100">Markdown 语法速查</h3>
<button @click="showMdHelp = false" class="p-1 text-gray-500 hover:text-gray-300 hover:bg-gray-800 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 内容 -->
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<div v-for="section in mdHelpSections" :key="section.title">
<h4 class="text-sm font-bold text-indigo-400 mb-3 flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-indigo-500 rounded-full"></span>
{{ section.title }}
</h4>
<div class="space-y-2">
<div v-for="(item, i) in section.items" :key="i" class="flex gap-4 bg-gray-800/40 rounded-lg px-4 py-2.5">
<code class="flex-1 text-xs text-amber-300/90 font-mono whitespace-pre">{{ item.syntax }}</code>
<span class="flex-1 text-xs text-gray-400">{{ item.desc }}</span>
</div>
</div>
</div>
<!-- 提示 -->
<div class="bg-indigo-600/10 border border-indigo-600/20 rounded-lg px-4 py-3">
<p class="text-xs text-indigo-300">💡 小技巧选中文字后点击工具栏按钮可直接为选中内容添加格式支持拖拽/粘贴图片直接上传</p>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import MarkdownIt from 'markdown-it'
import { postsApi, uploadApi, categoryApi, aiFormatApi } from '../api/modules'
const md = new MarkdownIt({ html: true, breaks: true, linkify: true })
const route = useRoute()
const router = useRouter()
const AUTOSAVE_INTERVAL = 30000 // 30秒
const LOCAL_DRAFT_KEY = 'geekcode_draft'
const isEdit = computed(() => !!route.params.id)
const submitting = ref(false)
const uploading = ref(false)
const uploadingAttachment = ref(false)
const attachments = ref([])
const tagInput = ref('')
const editorRef = ref(null)
const editorMode = ref('edit')
const autoSaveStatus = ref('') // '' | 'saving' | 'saved' | 'failed'
const showMdHelp = ref(false)
const isDraft = ref(false)
const savingDraft = ref(false)
const draftPostId = ref(null) // 草稿对应的post ID
const hasUnsavedChanges = ref(false)
const lastSavedSnapshot = ref('')
const formatting = ref(false)
let autoSaveTimer = null
let autoSaveStatusTimer = null
const categories = ref([])
const form = ref({
title: '',
content: '',
category: '',
tags: [],
is_public: true,
})
const templates = [
{
name: '踩坑记录',
content: `## 问题描述\n\n\n## 错误信息\n\n\`\`\`\n\n\`\`\`\n\n## 排查过程\n\n\n## 解决方案\n\n`,
},
{
name: '技术方案',
content: `## 背景\n\n\n## 方案对比\n\n| 方案 | 优点 | 缺点 |\n|------|------|------|\n| 方案A | | |\n| 方案B | | |\n\n## 最终选择\n\n\n## 实施效果\n\n`,
},
{
name: '工具使用',
content: `## 工具介绍\n\n\n## 安装配置\n\n\`\`\`bash\n\n\`\`\`\n\n## 使用技巧\n\n\n## 注意事项\n\n`,
},
]
const toolbarBtns = [
{ tip: '加粗', text: '<span class="font-bold text-xs">B</span>', action: () => insertMd('**', '**') },
{ tip: '斜体', text: '<span class="italic text-xs">I</span>', action: () => insertMd('*', '*') },
{ tip: '删除线', text: '<span class="line-through text-xs">S</span>', action: () => insertMd('~~', '~~'), divider: true },
{ tip: '标题', icon: 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z', action: () => insertMd('## ', '') },
{ tip: '列表', icon: 'M4 6h16M4 10h16M4 14h16M4 18h16', action: () => insertMd('- ', '') },
{ tip: '引用', icon: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z', action: () => insertMd('> ', '') },
{ tip: '行内代码', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4', action: () => insertMd('`', '`') },
{ tip: '代码块', text: '<span class="text-[10px] font-mono">{}</span>', action: () => insertCodeBlock() },
{ tip: '链接', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', action: () => insertMd('[\u94fe\u63a5\u6587\u5b57](', ')'), divider: true },
{ tip: '插入图片', icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', tag: 'label' },
{ tip: '表格', icon: 'M3 10h18M3 14h18M10 3v18M14 3v18', action: () => insertTable() },
]
const preview = computed(() => form.value.content ? md.render(form.value.content) : '')
const mdHelpSections = [
{
title: '标题',
items: [
{ syntax: '# 一级标题', desc: '最大的标题' },
{ syntax: '## 二级标题', desc: '次级标题' },
{ syntax: '### 三级标题', desc: '小节标题(最常用)' },
{ syntax: '#### 四级标题', desc: '更小层级,最多支持到六级' },
],
},
{
title: '文本样式',
items: [
{ syntax: '**加粗文字**', desc: '加粗显示' },
{ syntax: '*斜体文字*', desc: '斜体显示' },
{ syntax: '~~删除线~~', desc: '添加删除线效果' },
{ syntax: '**_加粗斜体_**', desc: '同时加粗和斜体' },
{ syntax: '`行内代码`', desc: '标记行内代码或命令' },
],
},
{
title: '列表',
items: [
{ syntax: '- 无序列表项\n- 另一个列表项', desc: '用 - 或 * 创建无序列表' },
{ syntax: '1. 有序列表项\n2. 另一个列表项', desc: '用数字加点创建有序列表' },
{ syntax: '- [ ] 待办事项\n- [x] 已完成', desc: '创建任务清单' },
],
},
{
title: '引用与分隔线',
items: [
{ syntax: '> 这是一段引用文字', desc: '用于引用他人的话或重点内容' },
{ syntax: '> 多级引用\n>> 嵌套引用', desc: '可以嵌套多层引用' },
{ syntax: '---', desc: '水平分隔线' },
],
},
{
title: '链接与图片',
items: [
{ syntax: '[显示文字](https://url)', desc: '创建超链接' },
{ syntax: '![图片描述](https://图片url)', desc: '插入图片(支持拖拽/粘贴)' },
{ syntax: '[![图片](url)](链接url)', desc: '可点击的图片链接' },
],
},
{
title: '代码',
items: [
{ syntax: '`console.log("hello")`', desc: '行内代码' },
{ syntax: '```javascript\nconst x = 1;\n```', desc: '代码块,支持语法高亮' },
{ syntax: '```python\nprint("hi")\n```', desc: '指定语言js/py/bash/sql 等' },
],
},
{
title: '表格',
items: [
{ syntax: '| 表头1 | 表头2 |\n|-------|-------|\n| 内容1 | 内容2 |', desc: '用竖线和横线创建表格' },
{ syntax: '| 左对齐 | 居中 | 右对齐 |\n|:------|:----:|------:|', desc: '冒号控制对齐方式' },
],
},
{
title: '其他实用语法',
items: [
{ syntax: '脚注文字[^1]\n\n[^1]: 脚注内容', desc: '添加脚注说明' },
{ syntax: '<details>\n<summary>点击展开</summary>\n隐藏内容\n</details>', desc: '折叠/展开内容块' },
{ syntax: '\\n 或空行', desc: '换行:行末两空格或空一行分段' },
],
},
]
const wordCount = computed(() => form.value.content.replace(/\s/g, '').length)
const readTime = computed(() => Math.max(1, Math.ceil(wordCount.value / 400)))
onMounted(async () => {
try {
const { data } = await categoryApi.getActiveCategories()
categories.value = data
} catch (e) { console.error(e) }
if (isEdit.value) {
try {
const res = await postsApi.getPost(route.params.id)
const post = res.data
form.value = {
title: post.title,
content: post.content,
category: post.category || '',
tags: tryParseJson(post.tags),
is_public: post.is_public,
}
isDraft.value = post.is_draft || false
draftPostId.value = post.id
} catch (e) { console.error(e) }
// 加载已有附件
try {
const { data } = await postsApi.getAttachments(route.params.id)
attachments.value = data
} catch (e) { console.error(e) }
} else {
// 新建文章:检查本地草稿
const localDraft = loadLocalDraft()
if (localDraft && (localDraft.title || localDraft.content)) {
if (confirm('检测到未完成的草稿,是否继续编辑?')) {
form.value = {
title: localDraft.title || '',
content: localDraft.content || '',
category: localDraft.category || '',
tags: localDraft.tags || [],
is_public: localDraft.is_public !== false,
}
if (localDraft.draftPostId) {
draftPostId.value = localDraft.draftPostId
isDraft.value = true
}
} else {
clearLocalDraft()
}
}
}
// 记录初始快照
lastSavedSnapshot.value = getFormSnapshot()
// 启动自动保存定时器
autoSaveTimer = setInterval(autoSave, AUTOSAVE_INTERVAL)
// 监听页面关闭事件
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
if (autoSaveTimer) clearInterval(autoSaveTimer)
if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
// 路由离开提醒
onBeforeRouteLeave((to, from, next) => {
if (hasUnsavedChanges.value) {
if (confirm('有未保存的更改,确定离开吗?')) {
next()
} else {
next(false)
}
} else {
next()
}
})
// 页面关闭/刷新提醒
function handleBeforeUnload(e) {
if (hasUnsavedChanges.value) {
e.preventDefault()
e.returnValue = ''
}
}
// 监听表单变化
watch(form, () => {
const snapshot = getFormSnapshot()
hasUnsavedChanges.value = snapshot !== lastSavedSnapshot.value
}, { deep: true })
// 快照生成
function getFormSnapshot() {
return JSON.stringify({
title: form.value.title,
content: form.value.content,
category: form.value.category,
tags: form.value.tags,
is_public: form.value.is_public,
})
}
// 本地草稿存储
function saveLocalDraft() {
try {
localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
...form.value,
draftPostId: draftPostId.value,
savedAt: Date.now(),
}))
} catch (e) { /* ignore */ }
}
function loadLocalDraft() {
try {
const data = localStorage.getItem(LOCAL_DRAFT_KEY)
if (!data) return null
const parsed = JSON.parse(data)
// 超过7天的本地草稿自动清除
if (Date.now() - parsed.savedAt > 7 * 24 * 60 * 60 * 1000) {
clearLocalDraft()
return null
}
return parsed
} catch { return null }
}
function clearLocalDraft() {
try { localStorage.removeItem(LOCAL_DRAFT_KEY) } catch { /* ignore */ }
}
// 自动保存逻辑
async function autoSave() {
// 没有内容或没有变化则跳过
if (!form.value.title && !form.value.content) return
if (!hasUnsavedChanges.value) return
autoSaveStatus.value = 'saving'
try {
const draftData = { ...form.value, is_draft: true }
if (draftPostId.value) {
// 更新已有草稿
await postsApi.updatePost(draftPostId.value, draftData)
} else {
// 创建新草稿
const res = await postsApi.createPost(draftData)
draftPostId.value = res.data.id
isDraft.value = true
}
lastSavedSnapshot.value = getFormSnapshot()
hasUnsavedChanges.value = false
autoSaveStatus.value = 'saved'
clearLocalDraft()
// 3秒后清除提示
if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer)
autoSaveStatusTimer = setTimeout(() => { autoSaveStatus.value = '' }, 3000)
} catch (e) {
console.error('自动保存失败,落入本地缓存', e)
saveLocalDraft()
autoSaveStatus.value = 'failed'
if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer)
autoSaveStatusTimer = setTimeout(() => { autoSaveStatus.value = '' }, 5000)
}
}
// 手动保存草稿
async function saveDraft() {
savingDraft.value = true
try {
const draftData = { ...form.value, is_draft: true }
if (draftPostId.value) {
await postsApi.updatePost(draftPostId.value, draftData)
} else {
const res = await postsApi.createPost(draftData)
draftPostId.value = res.data.id
isDraft.value = true
}
lastSavedSnapshot.value = getFormSnapshot()
hasUnsavedChanges.value = false
clearLocalDraft()
autoSaveStatus.value = 'saved'
if (autoSaveStatusTimer) clearTimeout(autoSaveStatusTimer)
autoSaveStatusTimer = setTimeout(() => { autoSaveStatus.value = '' }, 3000)
} catch (e) {
console.error(e)
saveLocalDraft()
alert(e.response?.data?.detail || '保存草稿失败,已缓存到本地')
} finally {
savingDraft.value = false
}
}
function tryParseJson(str) {
try { return JSON.parse(str) } catch { return [] }
}
function applyTemplate(tpl) {
if (form.value.content && !confirm('当前内容将被替换,确定?')) return
form.value.content = tpl.content
editorMode.value = 'edit'
nextTick(() => editorRef.value?.focus())
}
function addTag() {
const tag = tagInput.value.trim()
if (tag && !form.value.tags.includes(tag)) form.value.tags.push(tag)
tagInput.value = ''
}
// Markdown 快捷插入
function insertMd(before, after) {
const textarea = editorRef.value
if (!textarea) {
form.value.content += before + after
return
}
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = form.value.content.substring(start, end)
const text = before + (selected || '') + after
insertTextAtCursor(text)
if (!selected) {
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + before.length
textarea.focus()
})
}
}
function insertCodeBlock() {
insertMd('\n```\n', '\n```\n')
}
function insertTable() {
insertMd('\n| 列一 | 列二 | 列三 |\n|------|------|------|\n| | | |\n', '')
}
function insertTextAtCursor(text) {
const textarea = editorRef.value
if (!textarea) {
form.value.content += text
return
}
const start = textarea.selectionStart
const end = textarea.selectionEnd
form.value.content = form.value.content.substring(0, start) + text + form.value.content.substring(end)
const newPos = start + text.length
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = newPos
textarea.focus()
})
}
// 图片上传
async function uploadAndInsert(files) {
if (!files || files.length === 0) return
uploading.value = true
for (const file of files) {
if (!file.type.startsWith('image/')) continue
try {
const res = await uploadApi.uploadImage(file)
const url = res.data.url
const alt = file.name.replace(/\.[^.]+$/, '')
insertTextAtCursor(`![${alt}](${url})\n`)
} catch (e) {
console.error('图片上传失败', e)
alert(e.response?.data?.detail || '图片上传失败')
}
}
uploading.value = false
}
function handleImageUpload(e) {
uploadAndInsert(e.target.files)
e.target.value = ''
}
function handlePaste(e) {
const items = e.clipboardData?.items
if (!items) return
const imageFiles = []
for (const item of items) {
if (item.type.startsWith('image/')) imageFiles.push(item.getAsFile())
}
if (imageFiles.length > 0) {
e.preventDefault()
uploadAndInsert(imageFiles)
}
}
function handleDrop(e) {
const files = e.dataTransfer?.files
if (files?.length) uploadAndInsert(files)
}
// ===== 智能编辑增强(类飞书体验) =====
// 获取光标所在行的信息
function getCurrentLineInfo(textarea) {
const val = textarea.value
const cursor = textarea.selectionStart
const lineStart = val.lastIndexOf('\n', cursor - 1) + 1
const lineEnd = val.indexOf('\n', cursor)
const line = val.substring(lineStart, lineEnd === -1 ? val.length : lineEnd)
return { lineStart, lineEnd, line, cursor }
}
// 处理键盘事件
function handleEditorKeydown(e) {
const textarea = e.target
// --- Enter 键:智能续行 ---
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.isComposing) {
const { lineStart, line, cursor } = getCurrentLineInfo(textarea)
const beforeCursor = line.substring(0, cursor - lineStart)
// 匹配无序列表:- 或 * 开头
const ulMatch = beforeCursor.match(/^(\s*)([-*])\s(.*)/)
if (ulMatch) {
const [, indent, marker, content] = ulMatch
if (!content.trim()) {
// 空列表项,回车退出列表
e.preventDefault()
const newVal = textarea.value.substring(0, lineStart) + '\n' + textarea.value.substring(cursor)
form.value.content = newVal
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = lineStart + 1 })
} else {
// 续行
e.preventDefault()
const insert = '\n' + indent + marker + ' '
const pos = cursor
form.value.content = textarea.value.substring(0, pos) + insert + textarea.value.substring(pos)
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = pos + insert.length })
}
return
}
// 匹配有序列表1. 2. 等
const olMatch = beforeCursor.match(/^(\s*)(\d+)\.\s(.*)/)
if (olMatch) {
const [, indent, num, content] = olMatch
if (!content.trim()) {
e.preventDefault()
const newVal = textarea.value.substring(0, lineStart) + '\n' + textarea.value.substring(cursor)
form.value.content = newVal
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = lineStart + 1 })
} else {
e.preventDefault()
const next = parseInt(num) + 1
const insert = '\n' + indent + next + '. '
const pos = cursor
form.value.content = textarea.value.substring(0, pos) + insert + textarea.value.substring(pos)
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = pos + insert.length })
}
return
}
// 匹配任务列表:- [ ] 或 - [x]
const taskMatch = beforeCursor.match(/^(\s*[-*])\s\[[ x]\]\s(.*)/)
if (taskMatch) {
const [, prefix, content] = taskMatch
if (!content.trim()) {
e.preventDefault()
const newVal = textarea.value.substring(0, lineStart) + '\n' + textarea.value.substring(cursor)
form.value.content = newVal
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = lineStart + 1 })
} else {
e.preventDefault()
const insert = '\n' + prefix + ' [ ] '
const pos = cursor
form.value.content = textarea.value.substring(0, pos) + insert + textarea.value.substring(pos)
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = pos + insert.length })
}
return
}
// 匹配引用:> 开头
const quoteMatch = beforeCursor.match(/^(\s*>+\s)(.*)/)
if (quoteMatch) {
const [, prefix, content] = quoteMatch
if (!content.trim()) {
e.preventDefault()
const newVal = textarea.value.substring(0, lineStart) + '\n' + textarea.value.substring(cursor)
form.value.content = newVal
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = lineStart + 1 })
} else {
e.preventDefault()
const insert = '\n' + prefix
const pos = cursor
form.value.content = textarea.value.substring(0, pos) + insert + textarea.value.substring(pos)
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = pos + insert.length })
}
return
}
}
// --- Tab 键:缩进/反缩进 ---
if (e.key === 'Tab') {
e.preventDefault()
const start = textarea.selectionStart
const end = textarea.selectionEnd
if (e.shiftKey) {
// 反缩进:删除行首的两个空格
const { lineStart, line } = getCurrentLineInfo(textarea)
if (line.startsWith(' ')) {
form.value.content = textarea.value.substring(0, lineStart) + line.substring(2) + textarea.value.substring(lineStart + line.length)
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 2) })
}
} else {
// 插入两个空格
form.value.content = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end)
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = start + 2 })
}
}
}
// 处理输入事件Markdown 快捷语法实时转换
function handleEditorInput(e) {
const textarea = e.target
const { lineStart, line, cursor } = getCurrentLineInfo(textarea)
const beforeCursor = line.substring(0, cursor - lineStart)
// 输入 `--- ` 或 `***` 加回车转为分隔线3个以上
// 无需额外处理Markdown 本身支持
// 输入 ``` 自动补全代码块
if (beforeCursor === '```' && e.inputType === 'insertText' && e.data === '`') {
const pos = cursor
const insert = '\n\n```'
form.value.content = textarea.value.substring(0, pos) + insert + textarea.value.substring(pos)
nextTick(() => { textarea.selectionStart = textarea.selectionEnd = pos + 1 })
}
}
// 附件上传
async function handleAttachmentUpload(e) {
const files = e.target.files
if (!files?.length) return
uploadingAttachment.value = true
const postId = isEdit.value ? route.params.id : 0
for (const file of files) {
try {
const res = await uploadApi.uploadAttachment(file, postId)
attachments.value.push(res.data)
} catch (err) {
alert(err.response?.data?.detail || `上传失败: ${file.name}`)
}
}
uploadingAttachment.value = false
e.target.value = ''
}
async function removeAttachment(id) {
if (!confirm('确认删除此附件?')) return
const postId = isEdit.value ? route.params.id : 0
try {
await postsApi.deleteAttachment(postId, id)
} catch (e) { /* 新建帖子时 post_id=0忽略错误 */ }
attachments.value = attachments.value.filter(a => a.id !== id)
}
function formatFileSize(bytes) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
async function handleSubmit() {
submitting.value = true
try {
let postId
const publishData = { ...form.value, is_draft: false }
if (isEdit.value || draftPostId.value) {
// 编辑已有文章或从草稿发布
const pid = isEdit.value ? route.params.id : draftPostId.value
await postsApi.updatePost(pid, publishData)
postId = pid
} else {
const res = await postsApi.createPost(publishData)
postId = res.data.id
}
// 将未关联的附件关联到帖子
if (!isEdit.value && attachments.value.length) {
for (const att of attachments.value) {
if (!att.post_id || att.post_id === 0) {
try {
await uploadApi.updateAttachmentPost(att.id, postId)
} catch (e) { /* ignore */ }
}
}
}
hasUnsavedChanges.value = false
clearLocalDraft()
router.push('/')
} catch (e) {
console.error(e)
alert(e.response?.data?.detail || '保存失败')
} finally {
submitting.value = false
}
}
// AI 智能排版
async function aiFormat() {
if (!form.value.content.trim()) return
if (!confirm('AI 将重新排版当前内容并生成配图,原内容将被替换。确定继续吗?')) return
formatting.value = true
try {
const res = await aiFormatApi.formatArticle({
content: form.value.content,
generate_images: true,
})
form.value.content = res.formatted_content || res.data?.formatted_content
editorMode.value = 'preview'
const imgCount = res.images_generated || res.data?.images_generated || 0
if (imgCount > 0) {
alert(`排版完成,已生成 ${imgCount} 张配图`)
}
} catch (e) {
console.error('排版失败', e)
alert(e.response?.data?.detail || e.data?.detail || 'AI 排版失败,请稍后重试')
} finally {
formatting.value = false
}
}
</script>

View File

@@ -0,0 +1,416 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-4xl mx-auto px-6 py-6">
<!-- 用户信息卡片 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 mb-6">
<div class="flex items-start gap-5">
<!-- 头像 -->
<div class="relative group shrink-0">
<div
class="w-20 h-20 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-3xl text-white font-bold overflow-hidden cursor-pointer"
@click="triggerAvatarUpload"
>
<img v-if="userStore.user?.avatar" :src="userStore.user.avatar" class="w-full h-full object-cover" />
<span v-else>{{ userStore.user?.username?.[0]?.toUpperCase() }}</span>
</div>
<div
class="absolute inset-0 w-20 h-20 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
@click="triggerAvatarUpload"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div>
<input ref="avatarInput" type="file" accept="image/*" class="hidden" @change="handleAvatarChange" />
<div v-if="avatarUploading" class="absolute inset-0 w-20 h-20 rounded-full bg-black/60 flex items-center justify-center">
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold text-gray-100">{{ userStore.user?.username }}</h2>
<p class="text-sm text-gray-500 mt-1">{{ userStore.user?.email }}</p>
<div class="flex items-center gap-1.5 mt-2">
<span v-if="userStore.user?.is_admin" class="px-2 py-0.5 bg-indigo-600/20 text-indigo-400 text-[10px] rounded-full">管理员</span>
<span class="text-xs text-gray-600">加入于 {{ formatDate(userStore.user?.created_at) }}</span>
</div>
</div>
<!-- 编辑按钮 -->
<button
@click="showEditModal = true"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm rounded-lg transition-colors shrink-0"
>编辑资料</button>
</div>
<!-- 统计数据 -->
<div class="grid grid-cols-4 gap-4 mt-6 pt-5 border-t border-gray-800">
<div class="text-center">
<p class="text-2xl font-bold text-gray-100">{{ stats.post_count }}</p>
<p class="text-xs text-gray-500 mt-1">文章</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-gray-100">{{ stats.total_likes }}</p>
<p class="text-xs text-gray-500 mt-1">获赞</p>
</div>
<div class="text-center cursor-pointer hover:text-indigo-400" @click="activeTab = 'followers'; loadFollowers()">
<p class="text-2xl font-bold text-gray-100">{{ stats.follower_count }}</p>
<p class="text-xs text-gray-500 mt-1">粉丝</p>
</div>
<div class="text-center cursor-pointer hover:text-indigo-400" @click="activeTab = 'following'; loadFollowing()">
<p class="text-2xl font-bold text-gray-100">{{ stats.following_count }}</p>
<p class="text-xs text-gray-500 mt-1">关注</p>
</div>
</div>
</div>
<!-- Tab 切换 -->
<div class="flex gap-1 bg-gray-900 rounded-lg p-1 mb-5 w-fit">
<button
v-for="t in tabs" :key="t.key"
@click="switchTab(t.key)"
class="px-4 py-2 text-sm rounded-md transition-colors"
:class="activeTab === t.key ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-gray-200'"
>{{ t.label }}</button>
</div>
<!-- 我的发布 -->
<div v-if="activeTab === 'posts'" class="space-y-3">
<div
v-for="post in myPosts" :key="post.id"
@click="$router.push(`/post/${post.id}`)"
class="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors group"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<h3 class="text-base font-medium text-gray-200 group-hover:text-indigo-400 transition-colors">{{ post.title }}</h3>
<p class="text-sm text-gray-500 mt-1.5 line-clamp-2">{{ stripHtml(post.content) }}</p>
</div>
<span v-if="post.category" class="px-2.5 py-1 bg-gray-800 text-gray-400 text-[11px] rounded-md shrink-0">{{ post.category }}</span>
</div>
<div class="flex items-center gap-4 mt-3 text-xs text-gray-600">
<span>{{ formatDate(post.created_at) }}</span>
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
{{ post.view_count }}
</span>
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
{{ post.like_count }}
</span>
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{{ post.comment_count }}
</span>
</div>
</div>
<p v-if="myPosts.length === 0" class="text-center text-gray-600 py-16 text-sm">还没有发布过文章</p>
</div>
<!-- 我的收藏 -->
<div v-if="activeTab === 'collects'" class="space-y-3">
<div
v-for="post in myCollects" :key="post.id"
@click="$router.push(`/post/${post.id}`)"
class="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors group"
>
<h3 class="text-base font-medium text-gray-200 group-hover:text-indigo-400 transition-colors">{{ post.title }}</h3>
<p class="text-sm text-gray-500 mt-1.5 line-clamp-2">{{ stripHtml(post.content) }}</p>
<div class="flex items-center gap-4 mt-3 text-xs text-gray-600">
<span v-if="post.author" class="text-gray-400">{{ post.author.username }}</span>
<span>{{ post.like_count }} </span>
<span>{{ post.comment_count }} 评论</span>
<span v-if="post.category" class="px-2 py-0.5 bg-gray-800 rounded">{{ post.category }}</span>
</div>
</div>
<p v-if="myCollects.length === 0" class="text-center text-gray-600 py-16 text-sm">还没有收藏过文章</p>
</div>
<!-- 粉丝列表 -->
<div v-if="activeTab === 'followers'" class="space-y-2">
<div
v-for="u in followers" :key="u.id"
@click="$router.push(`/user/${u.id}`)"
class="bg-gray-900 border border-gray-800 rounded-xl p-4 flex items-center gap-3 cursor-pointer hover:border-gray-700 transition-colors"
>
<div class="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center text-sm font-bold text-indigo-400 shrink-0 overflow-hidden">
<img v-if="u.avatar" :src="u.avatar" class="w-full h-full object-cover" />
<span v-else>{{ u.username?.charAt(0)?.toUpperCase() }}</span>
</div>
<span class="text-sm text-gray-200">{{ u.username }}</span>
</div>
<p v-if="followers.length === 0" class="text-center text-gray-600 py-16 text-sm">暂无粉丝</p>
</div>
<!-- 关注列表 -->
<div v-if="activeTab === 'following'" class="space-y-2">
<div
v-for="u in following" :key="u.id"
@click="$router.push(`/user/${u.id}`)"
class="bg-gray-900 border border-gray-800 rounded-xl p-4 flex items-center gap-3 cursor-pointer hover:border-gray-700 transition-colors"
>
<div class="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center text-sm font-bold text-indigo-400 shrink-0 overflow-hidden">
<img v-if="u.avatar" :src="u.avatar" class="w-full h-full object-cover" />
<span v-else>{{ u.username?.charAt(0)?.toUpperCase() }}</span>
</div>
<span class="text-sm text-gray-200">{{ u.username }}</span>
</div>
<p v-if="following.length === 0" class="text-center text-gray-600 py-16 text-sm">暂未关注任何人</p>
</div>
</div>
<!-- 编辑资料弹窗 -->
<Teleport to="body">
<div v-if="showEditModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showEditModal = false">
<div class="w-full max-w-md bg-gray-900 border border-gray-800 rounded-xl p-6 mx-4">
<h3 class="text-lg font-bold text-gray-100 mb-5">编辑个人资料</h3>
<!-- 基本信息 -->
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">用户名</label>
<input
v-model="editForm.username"
class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 text-sm"
placeholder="请输入用户名"
/>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">邮箱</label>
<input
v-model="editForm.email"
type="email"
class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 text-sm"
placeholder="请输入邮箱"
/>
</div>
<!-- 修改密码折叠区 -->
<div class="border-t border-gray-800 pt-4">
<button @click="showPwdFields = !showPwdFields" class="text-sm text-indigo-400 hover:text-indigo-300 transition-colors">
{{ showPwdFields ? '取消修改密码' : '修改密码' }}
</button>
<div v-if="showPwdFields" class="space-y-3 mt-3">
<div>
<label class="block text-sm text-gray-400 mb-1">当前密码</label>
<input
v-model="editForm.old_password"
type="password"
class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 text-sm"
placeholder="请输入当前密码"
/>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">新密码</label>
<input
v-model="editForm.new_password"
type="password"
class="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 text-sm"
placeholder="请输入新密码至少6位"
/>
</div>
</div>
</div>
</div>
<p v-if="editError" class="text-red-400 text-sm mt-3">{{ editError }}</p>
<p v-if="editSuccess" class="text-green-400 text-sm mt-3">{{ editSuccess }}</p>
<!-- 按钮 -->
<div class="flex justify-end gap-3 mt-6">
<button @click="showEditModal = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-colors">取消</button>
<button
@click="saveProfile"
:disabled="saving"
class="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
>{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useUserStore } from '../stores/user'
import { usersApi, authApi, postsApi, uploadApi } from '../api/modules'
const userStore = useUserStore()
// === 统计数据 ===
const stats = reactive({ post_count: 0, total_likes: 0, follower_count: 0, following_count: 0 })
// === Tab ===
const tabs = [
{ key: 'posts', label: '我的发布' },
{ key: 'collects', label: '我的收藏' },
{ key: 'followers', label: '粉丝' },
{ key: 'following', label: '关注' },
]
const activeTab = ref('posts')
const myPosts = ref([])
const myCollects = ref([])
const followers = ref([])
const following = ref([])
// === 头像上传 ===
const avatarInput = ref(null)
const avatarUploading = ref(false)
function triggerAvatarUpload() {
avatarInput.value?.click()
}
async function handleAvatarChange(e) {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
alert('请选择图片文件')
return
}
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过5MB')
return
}
avatarUploading.value = true
try {
const uploadRes = await uploadApi.uploadImage(file)
const avatarUrl = uploadRes.data.url
await authApi.updateProfile({ avatar: avatarUrl })
userStore.updateUser({ ...userStore.user, avatar: avatarUrl })
} catch (err) {
alert(err.response?.data?.detail || '头像上传失败')
} finally {
avatarUploading.value = false
e.target.value = ''
}
}
// === 编辑弹窗 ===
const showEditModal = ref(false)
const showPwdFields = ref(false)
const saving = ref(false)
const editError = ref('')
const editSuccess = ref('')
const editForm = reactive({ username: '', email: '', old_password: '', new_password: '' })
// 打开编辑弹窗时填充当前信息
watch(showEditModal, (v) => {
if (v) {
editForm.username = userStore.user?.username || ''
editForm.email = userStore.user?.email || ''
editForm.old_password = ''
editForm.new_password = ''
showPwdFields.value = false
editError.value = ''
editSuccess.value = ''
}
})
onMounted(() => {
loadProfile()
loadPosts()
})
async function loadProfile() {
try {
const uid = userStore.user?.id
if (!uid) return
const res = await usersApi.getProfile(uid)
stats.post_count = res.data.post_count
stats.follower_count = res.data.follower_count
stats.following_count = res.data.following_count
// 计算总获赞:从文章列表中累加
let totalLikes = 0
try {
const postsRes = await usersApi.getUserPosts(uid, { page_size: 200 })
const posts = postsRes.data
totalLikes = posts.reduce((sum, p) => sum + (p.like_count || 0), 0)
} catch (e) { /* ignore */ }
stats.total_likes = totalLikes
} catch (e) { console.error(e) }
}
async function loadPosts() {
try {
const res = await usersApi.getUserPosts(userStore.user.id, { page_size: 100 })
myPosts.value = res.data
} catch (e) { myPosts.value = [] }
}
async function loadCollects() {
try {
const res = await usersApi.getUserCollects(userStore.user.id, { page_size: 100 })
myCollects.value = res.data
} catch (e) { myCollects.value = [] }
}
async function loadFollowers() {
try {
const res = await usersApi.getFollowers(userStore.user.id)
followers.value = res.data
} catch (e) { followers.value = [] }
}
async function loadFollowing() {
try {
const res = await usersApi.getFollowing(userStore.user.id)
following.value = res.data
} catch (e) { following.value = [] }
}
function switchTab(key) {
activeTab.value = key
if (key === 'posts' && myPosts.value.length === 0) loadPosts()
if (key === 'collects' && myCollects.value.length === 0) loadCollects()
if (key === 'followers' && followers.value.length === 0) loadFollowers()
if (key === 'following' && following.value.length === 0) loadFollowing()
}
async function saveProfile() {
editError.value = ''
editSuccess.value = ''
saving.value = true
try {
const payload = {}
if (editForm.username && editForm.username !== userStore.user?.username) {
payload.username = editForm.username
}
if (editForm.email && editForm.email !== userStore.user?.email) {
payload.email = editForm.email
}
if (showPwdFields.value && editForm.new_password) {
if (editForm.new_password.length < 6) {
editError.value = '新密码至少需要6位'
saving.value = false
return
}
payload.old_password = editForm.old_password
payload.new_password = editForm.new_password
}
if (Object.keys(payload).length === 0) {
editError.value = '没有需要修改的内容'
saving.value = false
return
}
const res = await authApi.updateProfile(payload)
userStore.updateUser(res.data)
editSuccess.value = '保存成功'
setTimeout(() => { showEditModal.value = false }, 800)
} catch (e) {
editError.value = e.response?.data?.detail || '保存失败,请重试'
} finally {
saving.value = false
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function stripHtml(str) {
if (!str) return ''
return str.replace(/<[^>]*>/g, '').replace(/[#*`~>\-|]/g, '').substring(0, 150)
}
</script>

View File

@@ -0,0 +1,385 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-5xl mx-auto px-6 py-6">
<!-- 标题区 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-bold text-gray-100">开源项目</h1>
<p class="text-xs text-gray-500 mt-1">精选优质开源项目推荐</p>
</div>
<!-- 搜索 -->
<div class="relative w-64">
<svg class="w-4 h-4 text-gray-600 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
v-model="searchQuery"
@keydown.enter="doSearch"
:placeholder="activeTab === 'github' ? '搜索 GitHub 项目...' : '搜索项目...'"
class="w-full pl-9 pr-8 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
<button v-if="searchQuery" @click="clearSearch" class="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-gray-600 hover:text-gray-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<!-- Tab 切换 -->
<div class="flex items-center gap-4 mb-4">
<button
v-for="tab in tabs"
:key="tab.key"
@click="switchTab(tab.key)"
class="text-sm pb-1 border-b-2 transition-colors"
:class="activeTab === tab.key ? 'text-indigo-400 border-indigo-400 font-medium' : 'text-gray-500 border-transparent hover:text-gray-300'"
>{{ tab.label }}</button>
</div>
<!-- 分类筛选 -->
<div v-if="categories.length > 0 && activeTab !== 'search' && activeTab !== 'github' && activeTab !== 'collects'" class="flex flex-wrap items-center gap-2 mb-5">
<button
@click="selectedCat = ''; loadData()"
class="px-3 py-1 text-xs rounded-lg transition-colors"
:class="!selectedCat ? 'bg-indigo-600 text-white' : 'bg-gray-900 border border-gray-800 text-gray-400 hover:text-gray-200'"
>全部</button>
<button
v-for="cat in categories"
:key="cat.name"
@click="selectedCat = cat.name; loadData()"
class="px-3 py-1 text-xs rounded-lg transition-colors"
:class="selectedCat === cat.name ? 'bg-indigo-600 text-white' : 'bg-gray-900 border border-gray-800 text-gray-400 hover:text-gray-200'"
>{{ cat.name }} <span class="text-[10px] opacity-60">{{ cat.count }}</span></button>
</div>
<!-- GitHub 搜索筛选条 -->
<div v-if="activeTab === 'github'" class="mb-5">
<!-- 搜索输入框 -->
<div class="flex items-center gap-2 mb-3">
<div class="relative flex-1">
<svg class="w-4 h-4 text-gray-600 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
v-model="searchQuery"
@keydown.enter="doGithubSearch"
placeholder="搜索 GitHub 项目,如 vue、react、todo app..."
class="w-full pl-9 pr-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
</div>
<select v-model="ghSort" @change="ghSearched && doGithubSearch()" class="px-2 py-2 bg-gray-900 border border-gray-800 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="stars"> Star 排序</option>
<option value="forks"> Fork 排序</option>
<option value="updated">最近更新</option>
</select>
<select v-model="ghLang" @change="ghSearched && doGithubSearch()" class="px-2 py-2 bg-gray-900 border border-gray-800 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="">全部语言</option>
<option v-for="l in langOptions" :key="l" :value="l">{{ l }}</option>
</select>
<button @click="doGithubSearch" :disabled="loading" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors shrink-0">
{{ loading ? '搜索中...' : '搜索' }}
</button>
<span v-if="ghTotal > 0" class="text-[10px] text-gray-600 shrink-0"> {{ formatNum(ghTotal) }} 个结果</span>
</div>
<!-- 快捷搜索预设 -->
<div class="flex flex-wrap gap-1.5">
<span class="text-[10px] text-gray-600 leading-5">快捷</span>
<button v-for="preset in quickPresets" :key="preset.label" @click="applyPreset(preset)" class="px-2 py-0.5 bg-gray-900 border border-gray-800 text-[10px] text-gray-500 rounded hover:text-indigo-400 hover:border-indigo-600/30 transition-colors">{{ preset.label }}</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="flex justify-center py-20">
<div class="w-7 h-7 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- 空状态 -->
<div v-else-if="projectList.length === 0" class="text-center py-20">
<svg class="w-14 h-14 text-gray-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<p class="text-sm text-gray-500">{{ activeTab === 'search' ? '没有找到匹配的项目' : activeTab === 'github' ? '输入关键词搜索 GitHub 项目,或点击快捷按钮' : activeTab === 'collects' ? '还没有收藏任何项目' : '暂无开源项目' }}</p>
</div>
<!-- 项目卡片网格 -->
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a
v-for="proj in projectList"
:key="proj.id || proj.github_id"
:href="proj.url"
target="_blank"
rel="noopener noreferrer"
class="group bg-gray-900 border border-gray-800 rounded-xl p-4 hover:border-indigo-600/40 hover:bg-gray-900/80 transition-all cursor-pointer block"
>
<!-- 收藏按钮 -->
<div class="flex items-center gap-3 mb-2.5">
<div class="w-10 h-10 rounded-lg bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0 group-hover:border-indigo-600/30 transition-colors">
<img v-if="proj.icon" :src="proj.icon" class="w-6 h-6 rounded" @error="$event.target.style.display='none'" />
<span v-else class="text-base font-bold text-indigo-400">{{ proj.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-200 group-hover:text-indigo-400 transition-colors truncate">{{ proj.name }}</div>
<div class="text-[10px] text-gray-700 truncate">{{ getDomain(proj.url) }}</div>
</div>
<button
v-if="proj.id && activeTab !== 'github'"
@click.stop.prevent="toggleCollect(proj)"
class="shrink-0 p-1 rounded transition-colors"
:class="proj.is_collected ? 'text-yellow-400 hover:text-yellow-300' : 'text-gray-700 hover:text-yellow-400'"
:title="proj.is_collected ? '取消收藏' : '收藏项目'"
>
<svg class="w-4 h-4" :fill="proj.is_collected ? 'currentColor' : 'none'" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</button>
</div>
<!-- 描述 -->
<p class="text-[12px] text-gray-500 line-clamp-2 mb-3 min-h-[2.4em]">{{ proj.description || '暂无描述' }}</p>
<!-- 底部标签 + 数据 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span v-if="proj.language" class="px-2 py-0.5 bg-gray-800 text-[10px] text-gray-400 rounded">{{ proj.language }}</span>
<span v-if="proj.category" class="px-2 py-0.5 bg-indigo-900/30 text-[10px] text-indigo-400 rounded">{{ proj.category }}</span>
<span v-if="activeTab === 'github'" class="px-1.5 py-0.5 bg-gray-800 text-[10px] text-gray-500 rounded flex items-center gap-1">
<svg class="w-2.5 h-2.5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</span>
</div>
<div class="flex items-center gap-3 text-[11px] text-gray-600">
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ formatNum(proj.stars) }}
</span>
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></svg>
{{ formatNum(proj.forks) }}
</span>
</div>
</div>
</a>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="flex justify-center mt-6">
<button @click="loadMore" :disabled="loadingMore" class="px-6 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-400 hover:text-gray-200 hover:border-gray-700 transition-colors disabled:opacity-40">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { projectsApi } from '../api/modules'
const tabs = [
{ key: 'hot', label: '热门项目' },
{ key: 'latest', label: '最新发布' },
{ key: 'collects', label: '我的收藏' },
{ key: 'github', label: 'GitHub 搜索' },
]
const activeTab = ref('hot')
const selectedCat = ref('')
const searchQuery = ref('')
const projectList = ref([])
const categories = ref([])
const loading = ref(true)
const loadingMore = ref(false)
const page = ref(1)
const total = ref(0)
const pageSize = 12
// GitHub 搜索相关
const ghSort = ref('stars')
const ghLang = ref('')
const ghTotal = ref(0)
const ghPage = ref(1)
const ghSearched = ref(false)
const langOptions = ['JavaScript', 'TypeScript', 'Python', 'Java', 'Go', 'Rust', 'C++', 'C', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin', 'Shell', 'Vue']
const quickPresets = [
{ label: '🔥 全球热门', keyword: '', lang: '', sort: 'stars' },
{ label: '🆕 新项目', keyword: 'created:>2025-01-01', lang: '', sort: 'stars' },
{ label: '⚙️ 前端框架', keyword: 'topic:frontend-framework', lang: 'JavaScript', sort: 'stars' },
{ label: '🛠️ 后端框架', keyword: 'topic:backend framework', lang: '', sort: 'stars' },
{ label: '🤖 AI / ML', keyword: 'topic:machine-learning OR topic:artificial-intelligence OR topic:deep-learning', lang: '', sort: 'stars' },
{ label: 'Vue 生态', keyword: 'topic:vue', lang: '', sort: 'stars' },
{ label: 'React 生态', keyword: 'topic:react', lang: '', sort: 'stars' },
{ label: 'Python 热门', keyword: '', lang: 'Python', sort: 'stars' },
{ label: 'Go 热门', keyword: '', lang: 'Go', sort: 'stars' },
{ label: 'Rust 热门', keyword: '', lang: 'Rust', sort: 'stars' },
]
const hasMore = computed(() => projectList.value.length < total.value)
onMounted(async () => {
await Promise.all([loadData(), loadCategories()])
})
async function loadCategories() {
try {
const res = await projectsApi.getCategories()
categories.value = res.data
} catch (e) { /* ignore */ }
}
async function loadData() {
loading.value = true
page.value = 1
try {
if (activeTab.value === 'github') {
// GitHub 搜索模式
if (!searchQuery.value.trim() && !ghSearched.value) {
projectList.value = []
total.value = 0
loading.value = false
return
}
await doGithubSearch()
return
}
if (activeTab.value === 'collects') {
const res = await projectsApi.getMyCollects({ page: 1, size: pageSize })
projectList.value = res.data.items
total.value = res.data.total
loading.value = false
return
}
const params = { page: 1, size: pageSize }
if (selectedCat.value) params.category = selectedCat.value
let res
if (activeTab.value === 'search') {
params.q = searchQuery.value
res = await projectsApi.search(params)
} else if (activeTab.value === 'latest') {
res = await projectsApi.getLatest(params)
} else {
res = await projectsApi.getHot(params)
}
projectList.value = res.data.items
total.value = res.data.total
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function loadMore() {
loadingMore.value = true
page.value++
try {
if (activeTab.value === 'github') {
ghPage.value++
const q = buildGithubQuery()
const res = await projectsApi.publicGithubSearch({ q, sort: ghSort.value, page: ghPage.value, per_page: pageSize })
projectList.value.push(...res.data.items)
total.value = Math.min(res.data.total, 1000) // GitHub API 限制
loadingMore.value = false
return
}
if (activeTab.value === 'collects') {
const res = await projectsApi.getMyCollects({ page: page.value, size: pageSize })
projectList.value.push(...res.data.items)
total.value = res.data.total
loadingMore.value = false
return
}
const params = { page: page.value, size: pageSize }
if (selectedCat.value) params.category = selectedCat.value
let res
if (activeTab.value === 'search') {
params.q = searchQuery.value
res = await projectsApi.search(params)
} else if (activeTab.value === 'latest') {
res = await projectsApi.getLatest(params)
} else {
res = await projectsApi.getHot(params)
}
projectList.value.push(...res.data.items)
total.value = res.data.total
} catch (e) { page.value-- }
finally { loadingMore.value = false }
}
function switchTab(key) {
if (activeTab.value === key) return
activeTab.value = key
selectedCat.value = ''
loadData()
}
function doSearch() {
if (!searchQuery.value.trim()) return
if (activeTab.value === 'github') {
doGithubSearch()
return
}
activeTab.value = 'search'
loadData()
}
function clearSearch() {
searchQuery.value = ''
if (activeTab.value === 'search') {
activeTab.value = 'hot'
loadData()
} else if (activeTab.value === 'github') {
projectList.value = []
total.value = 0
ghSearched.value = false
ghTotal.value = 0
}
}
// ========== GitHub 搜索 ==========
function buildGithubQuery() {
const parts = []
if (searchQuery.value.trim()) parts.push(searchQuery.value.trim())
parts.push('stars:>100')
if (ghLang.value) parts.push(`language:${ghLang.value}`)
if (parts.length === 0) parts.push('stars:>1000')
return parts.join(' ')
}
async function doGithubSearch() {
loading.value = true
ghPage.value = 1
ghSearched.value = true
try {
const q = buildGithubQuery()
const res = await projectsApi.publicGithubSearch({ q, sort: ghSort.value, page: 1, per_page: pageSize })
projectList.value = res.data.items
ghTotal.value = res.data.total
total.value = Math.min(res.data.total, 1000)
} catch (e) {
projectList.value = []
console.error(e)
} finally { loading.value = false }
}
function applyPreset(preset) {
searchQuery.value = preset.keyword
ghLang.value = preset.lang
ghSort.value = preset.sort
doGithubSearch()
}
function getDomain(url) {
try { return new URL(url).hostname } catch { return url.replace(/^https?:\/\//, '').split('/')[0] }
}
function formatNum(n) {
if (!n) return '0'
if (n >= 10000) return (n / 1000).toFixed(1) + 'k'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
async function toggleCollect(proj) {
try {
const res = await projectsApi.toggleCollect(proj.id)
proj.is_collected = res.data.collected
proj.collect_count = res.data.collect_count
// 如果在"我的收藏"标签页取消收藏,从列表中移除
if (activeTab.value === 'collects' && !res.data.collected) {
projectList.value = projectList.value.filter(p => p.id !== proj.id)
total.value = Math.max(0, total.value - 1)
}
} catch (e) { console.error(e) }
}
</script>

View File

@@ -0,0 +1,376 @@
<template>
<div class="h-full flex">
<!-- 左侧对话列表 -->
<div class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
<div class="p-4">
<button
@click="startNewChat"
class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm rounded-lg transition-colors"
>
+ 新建需求分析
</button>
</div>
<div class="flex-1 overflow-y-auto px-2">
<div
v-for="conv in conversations"
:key="conv.id"
@click="selectConversation(conv)"
class="px-3 py-2 mb-1 rounded-lg cursor-pointer text-sm truncate flex items-center justify-between group"
:class="currentConvId === conv.id ? 'bg-gray-800 text-white' : 'text-gray-400 hover:bg-gray-800/50'"
>
<span class="truncate">{{ conv.title }}</span>
<button
@click.stop="deleteConversation(conv.id)"
class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 ml-2 shrink-0"
>
x
</button>
</div>
<p v-if="conversations.length === 0" class="text-gray-600 text-sm text-center py-8">
暂无对话记录
</p>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="flex-1 flex flex-col">
<!-- 消息列表 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- 欢迎提示 -->
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center max-w-lg">
<h2 class="text-2xl font-bold text-gray-300 mb-4">需求理解助手</h2>
<p class="text-gray-500 mb-6">
把甲方发来的内容粘贴到这里口语化文字截图等AI帮你整理成清晰的功能清单
</p>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400">
粘贴微信聊天记录
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400">
上传需求截图
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400">
描述产品想法
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400">
导入需求文档
</div>
</div>
</div>
</div>
<!-- 消息列表 -->
<div v-for="msg in messages" :key="msg.id || msg.tempId" class="flex gap-3">
<!-- 用户消息 -->
<div v-if="msg.role === 'user'" class="ml-auto max-w-2xl">
<div class="bg-indigo-600 rounded-2xl rounded-tr-md px-4 py-3 text-sm whitespace-pre-wrap">
{{ msg.content }}
</div>
<!-- 图片预览 -->
<div v-if="msg.images && msg.images.length" class="flex gap-2 mt-2 justify-end">
<img
v-for="(img, i) in msg.images"
:key="i"
:src="img"
class="h-20 rounded-lg object-cover cursor-pointer hover:opacity-80"
/>
</div>
</div>
<!-- AI消息 -->
<div v-else class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(msg.content)"></div>
</div>
</div>
</div>
<!-- 正在输入指示器 -->
<div v-if="isStreaming" class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(streamingContent)"></div>
<span class="inline-block w-2 h-4 bg-indigo-400 animate-pulse ml-0.5"></span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="border-t border-gray-800 p-4 bg-gray-900/50">
<!-- 模型选择器 -->
<div v-if="availableModels.length > 0" class="flex items-center gap-2 mb-3">
<span class="text-xs text-gray-500">模型:</span>
<select v-model="selectedModelId" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option :value="null">默认</option>
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.model_name || m.model_id }}
<template v-if="m.web_search_enabled"> 🌐</template>
<template v-if="m.is_default"> (默认)</template>
</option>
</select>
<span v-if="selectedModel?.web_search_enabled" class="text-xs text-blue-400">🌐 联网搜索</span>
</div>
<!-- 图片预览 -->
<div v-if="uploadedImages.length" class="flex gap-2 mb-3">
<div v-for="(img, i) in uploadedImages" :key="i" class="relative">
<img :src="img" class="h-16 rounded-lg object-cover" />
<button
@click="uploadedImages.splice(i, 1)"
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full text-xs flex items-center justify-center"
>
x
</button>
</div>
</div>
<div class="flex gap-3 items-end">
<!-- 上传图片按钮 -->
<label class="cursor-pointer text-gray-500 hover:text-indigo-400 transition-colors shrink-0 pb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="file" accept="image/*" class="hidden" @change="handleImageUpload" multiple />
</label>
<!-- 输入框 -->
<textarea
v-model="inputText"
@keydown.enter.exact="handleSend"
@input="autoResize"
ref="textareaRef"
placeholder="粘贴甲方需求、描述产品想法..."
rows="3"
class="flex-1 px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-xl text-gray-100 placeholder-gray-600 focus:outline-none focus:border-indigo-500 resize-none max-h-48 text-sm"
:disabled="isStreaming"
></textarea>
<!-- 发送按钮 -->
<button
@click="handleSend"
:disabled="isStreaming || (!inputText.trim() && !uploadedImages.length)"
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-30 text-white rounded-xl transition-colors shrink-0 text-sm"
>
发送
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onActivated, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import { requirementApi, aiModelsApi } from '../api/modules'
const md = new MarkdownIt({ html: true, breaks: true, linkify: true })
const conversations = ref([])
const currentConvId = ref(null)
const messages = ref([])
const inputText = ref('')
const uploadedImages = ref([])
const isStreaming = ref(false)
const streamingContent = ref('')
const messagesContainer = ref(null)
const textareaRef = ref(null)
const availableModels = ref([])
const selectedModelId = ref(null)
const selectedModel = computed(() =>
availableModels.value.find(m => m.id === selectedModelId.value)
)
onMounted(() => {
loadConversations()
loadModels()
checkPrefill()
})
onActivated(() => {
checkPrefill()
})
function checkPrefill() {
if (window.__prefillRequirement) {
inputText.value = window.__prefillRequirement
window.__prefillRequirement = null
nextTick(() => {
if (textareaRef.value) {
textareaRef.value.focus()
autoResize()
}
})
}
}
function renderMarkdown(text) {
if (!text) return ''
return md.render(text)
}
async function loadConversations() {
try {
const res = await requirementApi.getConversations()
conversations.value = res.data
} catch (e) {
console.error('加载对话列表失败', e)
}
}
async function loadModels() {
try {
const res = await aiModelsApi.getAvailableModels({ task_type: 'reasoning' })
availableModels.value = res.data
} catch (e) {
console.error('加载模型列表失败', e)
}
}
async function selectConversation(conv) {
currentConvId.value = conv.id
try {
const res = await requirementApi.getConversation(conv.id)
messages.value = res.data.messages.map(m => ({
...m,
images: m.image_urls ? tryParseJson(m.image_urls) : [],
}))
scrollToBottom()
} catch (e) {
console.error('加载对话详情失败', e)
}
}
function tryParseJson(str) {
try { return JSON.parse(str) } catch { return [] }
}
function startNewChat() {
currentConvId.value = null
messages.value = []
inputText.value = ''
uploadedImages.value = []
}
async function deleteConversation(id) {
try {
await requirementApi.deleteConversation(id)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentConvId.value === id) {
startNewChat()
}
} catch (e) {
console.error('删除失败', e)
}
}
async function handleImageUpload(e) {
const files = e.target.files
for (const file of files) {
try {
const res = await requirementApi.uploadImage(file)
uploadedImages.value.push(res.data.url)
} catch (err) {
console.error('上传图片失败', err)
}
}
e.target.value = ''
}
async function handleSend(e) {
if (e && e.shiftKey) return // Shift+Enter换行
if (e) e.preventDefault()
if (isStreaming.value) return
const text = inputText.value.trim()
const images = [...uploadedImages.value]
if (!text && !images.length) return
// 添加用户消息到界面
messages.value.push({
tempId: Date.now(),
role: 'user',
content: text,
images: images,
})
inputText.value = ''
uploadedImages.value = []
scrollToBottom()
// SSE流式请求
isStreaming.value = true
streamingContent.value = ''
const token = localStorage.getItem('token')
const body = JSON.stringify({
conversation_id: currentConvId.value,
content: text,
image_urls: images,
model_config_id: selectedModelId.value,
})
try {
const response = await fetch('/api/requirement/analyze', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: body,
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.done) {
// 完成
if (data.conversation_id) {
currentConvId.value = data.conversation_id
}
} else {
streamingContent.value += data.content
scrollToBottom()
}
} catch {}
}
}
}
// 将流式内容添加为AI消息
messages.value.push({
tempId: Date.now(),
role: 'assistant',
content: streamingContent.value,
})
streamingContent.value = ''
isStreaming.value = false
loadConversations()
} catch (e) {
console.error('请求失败', e)
isStreaming.value = false
streamingContent.value = ''
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
function autoResize() {
const el = textareaRef.value
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 192) + 'px'
}
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-4xl mx-auto px-6 py-8">
<h1 class="text-xl font-bold text-gray-100 mb-2">AI 工具库</h1>
<p class="text-sm text-gray-500 mb-8">智能工具助力编程提升开发效率</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 需求助手 -->
<div
@click="$router.push('/tools/requirement')"
class="bg-gray-900 border border-gray-800 rounded-xl p-6 cursor-pointer hover:border-indigo-600/50 hover:bg-gray-900/80 transition-all group"
>
<div class="w-10 h-10 rounded-xl bg-indigo-600/20 flex items-center justify-center mb-4">
<svg class="w-5 h-5 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
</div>
<h3 class="text-base font-medium text-gray-200 mb-2 group-hover:text-indigo-400 transition-colors">需求理解助手</h3>
<p class="text-sm text-gray-500 leading-relaxed">把甲方发来的内容粘贴进来AI帮你整理成清晰的功能清单用户故事和验收标准</p>
<div class="mt-4 flex items-center gap-2">
<span class="px-2 py-0.5 bg-indigo-600/10 text-indigo-400 text-xs rounded">产品经理视角</span>
<span class="px-2 py-0.5 bg-indigo-600/10 text-indigo-400 text-xs rounded">程序员视角</span>
</div>
</div>
<!-- 架构助手 -->
<div
@click="$router.push('/tools/architecture')"
class="bg-gray-900 border border-gray-800 rounded-xl p-6 cursor-pointer hover:border-emerald-600/50 hover:bg-gray-900/80 transition-all group"
>
<div class="w-10 h-10 rounded-xl bg-emerald-600/20 flex items-center justify-center mb-4">
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
</div>
<h3 class="text-base font-medium text-gray-200 mb-2 group-hover:text-emerald-400 transition-colors">架构选型助手</h3>
<p class="text-sm text-gray-500 leading-relaxed">输入项目需求AI给出技术选型建议数据库设计API接口清单和系统架构图</p>
<div class="mt-4 flex items-center gap-2">
<span class="px-2 py-0.5 bg-emerald-600/10 text-emerald-400 text-xs rounded">技术选型</span>
<span class="px-2 py-0.5 bg-emerald-600/10 text-emerald-400 text-xs rounded">架构设计</span>
</div>
</div>
<!-- API Hub -->
<div
@click="$router.push('/tools/api-hub')"
class="bg-gray-900 border border-gray-800 rounded-xl p-6 cursor-pointer hover:border-amber-600/50 hover:bg-gray-900/80 transition-all group"
>
<div class="w-10 h-10 rounded-xl bg-amber-600/20 flex items-center justify-center mb-4">
<svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
</div>
<h3 class="text-base font-medium text-gray-200 mb-2 group-hover:text-amber-400 transition-colors">API Hub</h3>
<p class="text-sm text-gray-500 leading-relaxed">团队共享API资源管理集中管理测试和监控各类公用API服务</p>
<div class="mt-4 flex items-center gap-2">
<span class="px-2 py-0.5 bg-amber-600/10 text-amber-400 text-xs rounded">密码保护</span>
<span class="px-2 py-0.5 bg-amber-600/10 text-amber-400 text-xs rounded">在线测试</span>
<span class="px-2 py-0.5 bg-amber-600/10 text-amber-400 text-xs rounded">健康监控</span>
</div>
</div>
<!-- 联网搜索 -->
<div
@click="$router.push('/tools/web-search')"
class="bg-gray-900 border border-gray-800 rounded-xl p-6 cursor-pointer hover:border-blue-600/50 hover:bg-gray-900/80 transition-all group"
>
<div class="w-10 h-10 rounded-xl bg-blue-600/20 flex items-center justify-center mb-4">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
<h3 class="text-base font-medium text-gray-200 mb-2 group-hover:text-blue-400 transition-colors">联网搜索助手</h3>
<p class="text-sm text-gray-500 leading-relaxed">基于豆包大模型的联网搜索实时获取最新信息并智能整合回答</p>
<div class="mt-4 flex items-center gap-2">
<span class="px-2 py-0.5 bg-blue-600/10 text-blue-400 text-xs rounded">实时搜索</span>
<span class="px-2 py-0.5 bg-blue-600/10 text-blue-400 text-xs rounded">智能整合</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,116 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-3xl mx-auto px-6 py-6">
<!-- 用户信息卡片 -->
<div v-if="profile" class="bg-gray-900 border border-gray-800 rounded-xl p-6 mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-full bg-gray-800 flex items-center justify-center text-xl font-bold text-indigo-400 shrink-0 overflow-hidden">
<img v-if="profile.avatar" :src="profile.avatar" class="w-full h-full object-cover" />
<span v-else>{{ profile.username?.charAt(0)?.toUpperCase() }}</span>
</div>
<div class="flex-1">
<h2 class="text-lg font-bold text-gray-100">{{ profile.username }}</h2>
<p class="text-sm text-gray-500 mt-0.5">{{ profile.email }}</p>
</div>
<button
v-if="!profile.is_self"
@click="toggleFollow"
class="px-4 py-1.5 rounded-lg text-sm transition-colors"
:class="profile.is_following ? 'bg-gray-800 text-gray-400 hover:text-red-400' : 'bg-indigo-600 hover:bg-indigo-700 text-white'"
>{{ profile.is_following ? '已关注' : '关注' }}</button>
</div>
<!-- 统计 -->
<div class="flex gap-6 mt-4 pt-4 border-t border-gray-800">
<div class="text-center">
<p class="text-base font-bold text-gray-200">{{ profile.post_count }}</p>
<p class="text-xs text-gray-500">文章</p>
</div>
<div class="text-center">
<p class="text-base font-bold text-gray-200">{{ profile.follower_count }}</p>
<p class="text-xs text-gray-500">粉丝</p>
</div>
<div class="text-center">
<p class="text-base font-bold text-gray-200">{{ profile.following_count }}</p>
<p class="text-xs text-gray-500">关注</p>
</div>
</div>
</div>
<!-- Tab 切换 -->
<div class="flex gap-1 mb-4 bg-gray-900 rounded-lg p-1 w-fit">
<button
@click="activeTab = 'posts'; loadContent()"
class="px-4 py-1.5 rounded-md text-sm transition-colors"
:class="activeTab === 'posts' ? 'bg-gray-800 text-white' : 'text-gray-500 hover:text-gray-300'"
>文章</button>
<button
@click="activeTab = 'collects'; loadContent()"
class="px-4 py-1.5 rounded-md text-sm transition-colors"
:class="activeTab === 'collects' ? 'bg-gray-800 text-white' : 'text-gray-500 hover:text-gray-300'"
>收藏</button>
</div>
<!-- 帖子列表 -->
<div v-if="postList.length === 0" class="text-center py-12">
<p class="text-sm text-gray-600">暂无内容</p>
</div>
<div v-else class="space-y-3">
<div
v-for="post in postList"
:key="post.id"
@click="$router.push(`/post/${post.id}`)"
class="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors"
>
<h3 class="text-base font-medium text-gray-200 mb-2">{{ post.title }}</h3>
<p class="text-sm text-gray-500 line-clamp-2 mb-3">{{ post.content?.replace(/<[^>]*>/g, '').substring(0, 150) }}</p>
<div class="flex items-center gap-4 text-xs text-gray-600">
<span>{{ post.like_count || 0 }} </span>
<span>{{ post.comment_count || 0 }} 评论</span>
<span v-if="post.category" class="px-2 py-0.5 bg-gray-800 rounded">{{ post.category }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { usersApi } from '../api/modules'
const route = useRoute()
const profile = ref(null)
const postList = ref([])
const activeTab = ref('posts')
const userId = () => parseInt(route.params.id)
onMounted(() => { load() })
watch(() => route.params.id, () => { load() })
async function load() {
try {
const res = await usersApi.getProfile(userId())
profile.value = res.data
} catch (e) { console.error(e) }
loadContent()
}
async function loadContent() {
try {
const res = activeTab.value === 'posts'
? await usersApi.getUserPosts(userId())
: await usersApi.getUserCollects(userId())
postList.value = res.data
} catch (e) { postList.value = [] }
}
async function toggleFollow() {
try {
const res = await usersApi.toggleFollow(userId())
profile.value.is_following = res.data.followed
profile.value.follower_count += res.data.followed ? 1 : -1
} catch (e) { console.error(e) }
}
</script>

View File

@@ -0,0 +1,330 @@
<template>
<div class="h-full flex">
<!-- 左侧对话列表 -->
<div class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
<div class="p-4">
<button
@click="startNewChat"
class="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
>
+ 新建搜索
</button>
</div>
<div class="flex-1 overflow-y-auto px-2">
<div
v-for="conv in conversations"
:key="conv.id"
@click="selectConversation(conv)"
class="px-3 py-2 mb-1 rounded-lg cursor-pointer text-sm truncate flex items-center justify-between group"
:class="currentConvId === conv.id ? 'bg-gray-800 text-white' : 'text-gray-400 hover:bg-gray-800/50'"
>
<span class="truncate">{{ conv.title }}</span>
<button
@click.stop="deleteConversation(conv.id)"
class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 ml-2 shrink-0"
>
x
</button>
</div>
<p v-if="conversations.length === 0" class="text-gray-600 text-sm text-center py-8">
暂无搜索记录
</p>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="flex-1 flex flex-col">
<!-- 消息列表 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- 欢迎提示 -->
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center max-w-lg">
<div class="w-16 h-16 rounded-2xl bg-blue-600/20 flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-300 mb-4">联网搜索助手</h2>
<p class="text-gray-500 mb-6">
基于豆包大模型的联网搜索获取实时信息并智能整合回答
</p>
<div class="grid grid-cols-2 gap-3 text-sm">
<div
@click="quickSearch('今天有什么科技新闻?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
今天科技新闻
</div>
<div
@click="quickSearch('最新的前端框架趋势是什么?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
前端框架趋势
</div>
<div
@click="quickSearch('Python 3.13 有哪些新特性?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
Python 最新版本
</div>
<div
@click="quickSearch('目前最受欢迎的 AI 编程工具有哪些?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
AI 编程工具推荐
</div>
</div>
</div>
</div>
<!-- 消息列表 -->
<div v-for="msg in messages" :key="msg.id || msg.tempId" class="flex gap-3">
<!-- 用户消息 -->
<div v-if="msg.role === 'user'" class="ml-auto max-w-2xl">
<div class="bg-blue-600 rounded-2xl rounded-tr-md px-4 py-3 text-sm whitespace-pre-wrap">
{{ msg.content }}
</div>
</div>
<!-- AI消息 -->
<div v-else class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(msg.content)"></div>
</div>
</div>
</div>
<!-- 正在输入指示器 -->
<div v-if="isStreaming" class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div v-if="streamingContent" class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(streamingContent)"></div>
<div v-else class="flex items-center gap-2 text-gray-500 text-sm">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
正在联网搜索...
</div>
<span v-if="streamingContent" class="inline-block w-2 h-4 bg-blue-400 animate-pulse ml-0.5"></span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="border-t border-gray-800 p-4 bg-gray-900/50">
<!-- 模型选择器 -->
<div v-if="availableModels.length > 0" class="flex items-center gap-2 mb-3">
<span class="text-xs text-gray-500">模型:</span>
<select v-model="selectedModelId" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-blue-500">
<option :value="null">默认</option>
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.model_name || m.model_id }}
<template v-if="m.web_search_enabled"> 🌐</template>
</option>
</select>
<span v-if="selectedModel?.web_search_enabled" class="text-xs text-blue-400">🌐 联网搜索</span>
</div>
<div class="flex gap-3 items-end">
<textarea
v-model="inputText"
@keydown.enter.exact="handleSend"
@input="autoResize"
ref="textareaRef"
placeholder="输入你想搜索的问题..."
rows="2"
class="flex-1 px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-xl text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 resize-none max-h-48 text-sm"
:disabled="isStreaming"
></textarea>
<button
@click="handleSend"
:disabled="isStreaming || !inputText.trim()"
class="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-30 text-white rounded-xl transition-colors shrink-0 text-sm"
>
搜索
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import { webSearchApi, aiModelsApi } from '../api/modules'
const md = new MarkdownIt({ html: true, breaks: true, linkify: true })
const conversations = ref([])
const currentConvId = ref(null)
const messages = ref([])
const inputText = ref('')
const isStreaming = ref(false)
const streamingContent = ref('')
const messagesContainer = ref(null)
const textareaRef = ref(null)
const availableModels = ref([])
const selectedModelId = ref(null)
const selectedModel = computed(() =>
availableModels.value.find(m => m.id === selectedModelId.value)
)
onMounted(() => {
loadConversations()
loadModels()
})
async function loadModels() {
try {
const res = await aiModelsApi.getAvailableModels({})
// 只显示启用了联网搜索的 ark 模型
availableModels.value = res.data.filter(m => m.provider === 'ark' && m.web_search_enabled)
} catch (e) {
console.error('加载模型列表失败', e)
}
}
function renderMarkdown(text) {
if (!text) return ''
return md.render(text)
}
async function loadConversations() {
try {
const res = await webSearchApi.getConversations()
conversations.value = res.data
} catch (e) {
console.error('加载对话列表失败', e)
}
}
async function selectConversation(conv) {
currentConvId.value = conv.id
try {
const res = await webSearchApi.getConversation(conv.id)
messages.value = res.data.messages || []
scrollToBottom()
} catch (e) {
console.error('加载对话详情失败', e)
}
}
function startNewChat() {
currentConvId.value = null
messages.value = []
inputText.value = ''
}
async function deleteConversation(id) {
try {
await webSearchApi.deleteConversation(id)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentConvId.value === id) {
startNewChat()
}
} catch (e) {
console.error('删除失败', e)
}
}
function quickSearch(text) {
inputText.value = text
handleSend()
}
async function handleSend(e) {
if (e && e.shiftKey) return
if (e) e.preventDefault()
if (isStreaming.value) return
const text = inputText.value.trim()
if (!text) return
// 添加用户消息到界面
messages.value.push({
tempId: Date.now(),
role: 'user',
content: text,
})
inputText.value = ''
scrollToBottom()
// SSE 流式请求
isStreaming.value = true
streamingContent.value = ''
const token = localStorage.getItem('token')
const body = JSON.stringify({
conversation_id: currentConvId.value,
content: text,
model_config_id: selectedModelId.value,
})
try {
const response = await fetch('/api/web-search/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: body,
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.done) {
if (data.conversation_id) {
currentConvId.value = data.conversation_id
}
} else {
streamingContent.value += data.content
scrollToBottom()
}
} catch {}
}
}
}
// 将流式内容添加为AI消息
messages.value.push({
tempId: Date.now(),
role: 'assistant',
content: streamingContent.value,
})
streamingContent.value = ''
isStreaming.value = false
loadConversations()
} catch (e) {
console.error('搜索请求失败', e)
isStreaming.value = false
streamingContent.value = ''
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
function autoResize() {
const el = textareaRef.value
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 192) + 'px'
}
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="h-full overflow-y-auto p-6">
<div class="max-w-2xl">
<h1 class="text-lg font-bold text-white mb-1">API Hub 管理</h1>
<p class="text-xs text-gray-500 mb-6">管理 API Hub 的访问密码</p>
<!-- 密码设置 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 class="text-sm font-medium text-gray-200 mb-1">访问密码</h3>
<p class="text-xs text-gray-500 mb-4">团队成员需要输入此密码才能进入 API Hub 页面</p>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs text-gray-400">当前状态</span>
<span v-if="hasPassword" class="text-xs text-green-400">已设置</span>
<span v-else class="text-xs text-yellow-400">未设置</span>
</div>
<div class="flex items-center gap-3">
<input v-model="newPassword" type="text" placeholder="输入新密码至少4位" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<button @click="setPassword" :disabled="!newPassword.trim() || saving" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ saving ? '设置中...' : '设置密码' }}
</button>
</div>
<p v-if="msg" class="text-xs mt-2" :class="msgOk ? 'text-green-400' : 'text-red-400'">{{ msg }}</p>
</div>
<!-- 统计信息 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 mt-4">
<h3 class="text-sm font-medium text-gray-200 mb-3">API Hub 概览</h3>
<div class="grid grid-cols-4 gap-4">
<div class="text-center">
<div class="text-xl font-bold text-indigo-400">{{ stats.total_apis }}</div>
<div class="text-[11px] text-gray-500">API总数</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-emerald-400">{{ stats.healthy_count }}</div>
<div class="text-[11px] text-gray-500">健康</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-amber-400">{{ stats.total_calls }}</div>
<div class="text-[11px] text-gray-500">总调用</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-gray-400">{{ stats.total_categories }}</div>
<div class="text-[11px] text-gray-500">分类数</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { apiHubApi } from '../../api/modules'
const hasPassword = ref(false)
const newPassword = ref('')
const saving = ref(false)
const msg = ref('')
const msgOk = ref(false)
const stats = ref({ total_apis: 0, total_calls: 0, total_categories: 0, healthy_count: 0 })
onMounted(async () => {
try {
const { data } = await apiHubApi.getPasswordStatus()
hasPassword.value = data.has_password
} catch (e) { /* 忽略 */ }
try {
const { data } = await apiHubApi.getStats()
stats.value = data
} catch (e) { /* 忽略可能没有hub_token */ }
})
async function setPassword() {
if (!newPassword.value.trim() || newPassword.value.length < 4) {
msg.value = '密码至少4位'
msgOk.value = false
return
}
saving.value = true
msg.value = ''
try {
await apiHubApi.setPassword(newPassword.value)
hasPassword.value = true
msg.value = '密码设置成功'
msgOk.value = true
newPassword.value = ''
} catch (e) {
msg.value = e.response?.data?.detail || '设置失败'
msgOk.value = false
} finally { saving.value = false }
}
</script>

View File

@@ -0,0 +1,225 @@
<template>
<div class="h-full overflow-y-auto p-6">
<div class="max-w-2xl">
<!-- 标题 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-bold text-white">分类管理</h1>
<p class="text-xs text-gray-500 mt-1">管理帖子分类拖拽调整排序顺序</p>
</div>
<button
@click="showAdd = true"
class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors"
>+ 新增分类</button>
</div>
<!-- 新增输入框 -->
<div v-if="showAdd" class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-4">
<div class="flex items-center gap-3">
<input
v-model="newName"
ref="addInput"
@keydown.enter="addCategory"
@keydown.escape="showAdd = false"
placeholder="输入分类名称"
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
<button @click="addCategory" :disabled="!newName.trim()" class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors">确定</button>
<button @click="showAdd = false; newName = ''" class="px-3 py-2 text-xs text-gray-500 hover:text-gray-300 transition-colors">取消</button>
</div>
<p v-if="addError" class="text-xs text-red-400 mt-2">{{ addError }}</p>
</div>
<!-- 分类列表 -->
<div v-if="loading" class="flex justify-center py-12">
<div class="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-else class="space-y-1.5">
<div
v-for="(cat, index) in categories"
:key="cat.id"
class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3 flex items-center gap-3 group"
>
<!-- 排序按钮 -->
<div class="flex flex-col gap-0.5 shrink-0">
<button
@click="moveUp(index)"
:disabled="index === 0"
class="p-0.5 text-gray-600 hover:text-gray-300 disabled:opacity-20 transition-colors"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
</button>
<button
@click="moveDown(index)"
:disabled="index === categories.length - 1"
class="p-0.5 text-gray-600 hover:text-gray-300 disabled:opacity-20 transition-colors"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
</div>
<!-- 名称 -->
<div class="flex-1 min-w-0">
<template v-if="editingId === cat.id">
<input
v-model="editName"
ref="editInput"
@keydown.enter="saveEdit(cat)"
@keydown.escape="cancelEdit"
class="w-full bg-gray-800 border border-indigo-500 rounded-lg px-3 py-1.5 text-sm text-gray-200 focus:outline-none"
/>
</template>
<template v-else>
<span class="text-sm text-gray-200">{{ cat.name }}</span>
</template>
</div>
<!-- 状态标签 -->
<span
class="px-2 py-0.5 text-[11px] rounded shrink-0 cursor-pointer transition-colors"
:class="cat.is_active ? 'bg-green-900/40 text-green-400' : 'bg-gray-800 text-gray-500'"
@click="toggleActive(cat)"
>{{ cat.is_active ? '启用' : '禁用' }}</span>
<!-- 操作 -->
<div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<template v-if="editingId === cat.id">
<button @click="saveEdit(cat)" class="p-1 text-green-400 hover:text-green-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</button>
<button @click="cancelEdit" class="p-1 text-gray-500 hover:text-gray-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</template>
<template v-else>
<button @click="startEdit(cat)" class="p-1 text-gray-500 hover:text-indigo-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="deleteCategory(cat)" class="p-1 text-gray-500 hover:text-red-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</template>
</div>
</div>
<div v-if="categories.length === 0" class="text-center py-12 text-gray-600 text-sm">
暂无分类点击右上角新增
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { adminApi } from '../../api/modules'
const categories = ref([])
const loading = ref(true)
const showAdd = ref(false)
const newName = ref('')
const addError = ref('')
const editingId = ref(null)
const editName = ref('')
const addInput = ref(null)
onMounted(() => loadCategories())
async function loadCategories() {
loading.value = true
try {
const { data } = await adminApi.getCategories()
categories.value = data
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function addCategory() {
if (!newName.value.trim()) return
addError.value = ''
try {
await adminApi.createCategory({ name: newName.value.trim() })
newName.value = ''
showAdd.value = false
await loadCategories()
} catch (e) {
addError.value = e.response?.data?.detail || '添加失败'
}
}
function startEdit(cat) {
editingId.value = cat.id
editName.value = cat.name
nextTick(() => {
const inputs = document.querySelectorAll('input')
inputs.forEach(i => { if (i.value === cat.name) i.focus() })
})
}
function cancelEdit() {
editingId.value = null
editName.value = ''
}
async function saveEdit(cat) {
if (!editName.value.trim()) return
try {
await adminApi.updateCategory(cat.id, { name: editName.value.trim() })
editingId.value = null
await loadCategories()
} catch (e) {
alert(e.response?.data?.detail || '修改失败')
}
}
async function toggleActive(cat) {
try {
await adminApi.updateCategory(cat.id, { is_active: !cat.is_active })
await loadCategories()
} catch (e) {
console.error(e)
}
}
async function deleteCategory(cat) {
if (!confirm(`确定删除分类「${cat.name}」吗?已发布的帖子不会受影响。`)) return
try {
await adminApi.deleteCategory(cat.id)
await loadCategories()
} catch (e) {
alert(e.response?.data?.detail || '删除失败')
}
}
async function moveUp(index) {
if (index <= 0) return
const items = [...categories.value]
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
await saveOrder(items)
}
async function moveDown(index) {
if (index >= categories.value.length - 1) return
const items = [...categories.value]
;[items[index], items[index + 1]] = [items[index + 1], items[index]]
await saveOrder(items)
}
async function saveOrder(items) {
categories.value = items
const payload = items.map((c, i) => ({ id: c.id, sort_order: i }))
try {
// 逐个更新排序
for (const item of payload) {
await adminApi.updateCategory(item.id, { sort_order: item.sort_order })
}
await loadCategories()
} catch (e) {
console.error(e)
}
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-6xl mx-auto px-6 py-8">
<h1 class="text-xl font-bold text-white mb-6">数据概览</h1>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-20">
<div class="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<template v-else>
<!-- 统计卡片 -->
<div class="grid grid-cols-4 gap-4 mb-8">
<div v-for="card in statCards" :key="card.label" class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div class="flex items-center justify-between mb-3">
<span class="text-xs text-gray-500">{{ card.label }}</span>
<div class="w-8 h-8 rounded-lg flex items-center justify-center" :class="card.bgColor" v-html="card.icon"></div>
</div>
<div class="text-2xl font-bold text-white">{{ card.value }}</div>
<div v-if="card.sub" class="text-xs text-gray-500 mt-1">{{ card.sub }}</div>
</div>
</div>
<!-- 趋势图 -->
<div class="grid grid-cols-2 gap-4 mb-8">
<!-- 用户注册趋势 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 class="text-sm font-medium text-gray-300 mb-4">7日注册趋势</h3>
<div class="flex items-end gap-2 h-32">
<div
v-for="item in stats.user_trend"
:key="item.date"
class="flex-1 flex flex-col items-center gap-1"
>
<div
class="w-full bg-indigo-500/30 rounded-t transition-all relative group"
:style="{ height: barHeight(item.count, stats.user_trend) }"
>
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
{{ item.count }}
</div>
</div>
<span class="text-[10px] text-gray-600">{{ item.date }}</span>
</div>
</div>
</div>
<!-- 发帖趋势 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 class="text-sm font-medium text-gray-300 mb-4">7日发帖趋势</h3>
<div class="flex items-end gap-2 h-32">
<div
v-for="item in stats.post_trend"
:key="item.date"
class="flex-1 flex flex-col items-center gap-1"
>
<div
class="w-full bg-green-500/30 rounded-t transition-all relative group"
:style="{ height: barHeight(item.count, stats.post_trend) }"
>
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] text-green-400 opacity-0 group-hover:opacity-100 transition-opacity">
{{ item.count }}
</div>
</div>
<span class="text-[10px] text-gray-600">{{ item.date }}</span>
</div>
</div>
</div>
</div>
<!-- 最近数据 -->
<div class="grid grid-cols-2 gap-4">
<!-- 最近注册用户 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 class="text-sm font-medium text-gray-300 mb-4">最近注册用户</h3>
<div class="space-y-2.5">
<div v-for="u in stats.recent_users" :key="u.id" class="flex items-center gap-3">
<div class="w-7 h-7 rounded-full bg-gray-800 flex items-center justify-center text-[10px] font-bold text-indigo-400 shrink-0">
{{ u.username.charAt(0).toUpperCase() }}
</div>
<div class="min-w-0 flex-1">
<div class="text-xs text-gray-200 truncate">{{ u.username }}</div>
<div class="text-[10px] text-gray-600 truncate">{{ u.email }}</div>
</div>
<span class="text-[10px] text-gray-600 shrink-0">{{ formatDate(u.created_at) }}</span>
</div>
<div v-if="!stats.recent_users?.length" class="text-xs text-gray-600 text-center py-4">暂无数据</div>
</div>
</div>
<!-- 最近发布帖子 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 class="text-sm font-medium text-gray-300 mb-4">最近发布文章</h3>
<div class="space-y-2.5">
<div v-for="p in stats.recent_posts" :key="p.id" class="flex items-center gap-3">
<div class="min-w-0 flex-1">
<div class="text-xs text-gray-200 truncate">{{ p.title }}</div>
<div class="text-[10px] text-gray-600">{{ p.author }}</div>
</div>
<span class="text-[10px] text-gray-600 shrink-0">{{ formatDate(p.created_at) }}</span>
</div>
<div v-if="!stats.recent_posts?.length" class="text-xs text-gray-600 text-center py-4">暂无数据</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { adminApi } from '../../api/modules'
const loading = ref(true)
const stats = ref({})
const statCards = computed(() => [
{
label: '用户总数', value: stats.value.total_users || 0,
sub: `今日新增 ${stats.value.today_users || 0}`,
bgColor: 'bg-indigo-500/15',
icon: '<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>',
},
{
label: '文章总数', value: stats.value.total_posts || 0,
sub: `今日新增 ${stats.value.today_posts || 0}`,
bgColor: 'bg-green-500/15',
icon: '<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>',
},
{
label: '今日活跃', value: stats.value.today_active || 0,
bgColor: 'bg-orange-500/15',
icon: '<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>',
},
{
label: '互动数据', value: (stats.value.total_likes || 0) + (stats.value.total_comments || 0),
sub: `${stats.value.total_likes || 0} 赞 / ${stats.value.total_comments || 0} 评论`,
bgColor: 'bg-pink-500/15',
icon: '<svg class="w-4 h-4 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>',
},
])
function barHeight(count, trend) {
const max = Math.max(...trend.map(t => t.count), 1)
const pct = (count / max) * 100
return pct < 4 ? '4px' : `${pct}%`
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getMonth() + 1}-${d.getDate()}`
}
onMounted(async () => {
try {
const res = await adminApi.getStats()
stats.value = res.data
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
})
</script>

View File

@@ -0,0 +1,430 @@
<template>
<div class="h-full overflow-y-auto p-6">
<div class="max-w-4xl">
<h1 class="text-lg font-bold text-white mb-1">知识库管理</h1>
<p class="text-xs text-gray-500 mb-5">管理团队知识库的内容分类和访问权限</p>
<!-- Tab 切换 -->
<div class="flex gap-1 mb-5 border-b border-gray-800 pb-0">
<button v-for="t in tabs" :key="t.key" @click="activeTab = t.key"
class="px-3 py-2 text-xs font-medium rounded-t-lg transition-colors -mb-px border-b-2"
:class="activeTab === t.key ? 'text-indigo-400 border-indigo-400 bg-indigo-600/10' : 'text-gray-500 border-transparent hover:text-gray-300'">
{{ t.label }}
</button>
</div>
<!-- 条目管理 -->
<div v-if="activeTab === 'items'">
<div class="flex items-center gap-3 mb-4">
<select v-model="itemFilter.category_id" @change="loadItems" class="px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<input v-model="itemFilter.keyword" @keyup.enter="loadItems" placeholder="搜索条目..." class="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<button @click="loadItems" class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-xs text-gray-300 rounded-lg transition-colors">搜索</button>
<button @click="showPostPicker = true" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-xs text-white rounded-lg transition-colors">+ 从帖子添加</button>
</div>
<div v-if="items.length === 0" class="text-center py-12 text-gray-600 text-xs">暂无条目</div>
<div v-else class="space-y-2">
<div v-for="item in items" :key="item.id" class="bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 flex items-center gap-4">
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-200 truncate">{{ item.title }}</div>
<div class="text-[11px] text-gray-500 mt-0.5">
<span v-if="item.category_name" class="text-indigo-400/70 mr-2">{{ item.category_name }}</span>
<span>来源帖子 #{{ item.post_id }}</span>
<span class="mx-1">·</span>
<span>{{ formatDate(item.created_at) }}</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="openEditItem(item)" class="px-2 py-1 text-[11px] text-gray-400 hover:text-indigo-400 transition-colors">编辑</button>
<button @click="deleteItem(item.id)" class="px-2 py-1 text-[11px] text-gray-400 hover:text-red-400 transition-colors">删除</button>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="itemTotal > itemFilter.page_size" class="flex justify-center gap-2 mt-4">
<button @click="itemFilter.page = Math.max(1, itemFilter.page - 1); loadItems()" :disabled="itemFilter.page <= 1" class="px-2 py-1 text-xs text-gray-400 disabled:opacity-30">上一页</button>
<span class="text-xs text-gray-500 py-1">{{ itemFilter.page }} / {{ Math.ceil(itemTotal / itemFilter.page_size) }}</span>
<button @click="itemFilter.page++; loadItems()" :disabled="itemFilter.page >= Math.ceil(itemTotal / itemFilter.page_size)" class="px-2 py-1 text-xs text-gray-400 disabled:opacity-30">下一页</button>
</div>
</div>
<!-- 分类管理 -->
<div v-if="activeTab === 'categories'">
<div class="flex items-center gap-3 mb-4">
<input v-model="newCatName" placeholder="新分类名称" class="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" @keyup.enter="createCategory" />
<button @click="createCategory" :disabled="!newCatName.trim()" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-xs text-white rounded-lg transition-colors">添加分类</button>
</div>
<div v-if="categories.length === 0" class="text-center py-8 text-gray-600 text-xs">暂无分类</div>
<div v-else class="space-y-2">
<div v-for="cat in categories" :key="cat.id" class="bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
<template v-if="editingCat?.id === cat.id">
<input v-model="editingCat.name" class="flex-1 px-2 py-1 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 focus:outline-none focus:border-indigo-500" @keyup.enter="saveCategory" />
<input v-model.number="editingCat.sort_order" type="number" class="w-16 px-2 py-1 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 focus:outline-none" placeholder="排序" />
<button @click="saveCategory" class="text-[11px] text-indigo-400 hover:text-indigo-300">保存</button>
<button @click="editingCat = null" class="text-[11px] text-gray-500 hover:text-gray-300">取消</button>
</template>
<template v-else>
<span class="flex-1 text-sm text-gray-200">{{ cat.name }}</span>
<span class="text-[11px] text-gray-600">排序: {{ cat.sort_order }}</span>
<span class="text-[11px] text-gray-600">{{ cat.item_count || 0 }} </span>
<button @click="editingCat = { ...cat }" class="text-[11px] text-gray-400 hover:text-indigo-400">编辑</button>
<button @click="deleteCategory(cat.id)" class="text-[11px] text-gray-400 hover:text-red-400">删除</button>
</template>
</div>
</div>
</div>
<!-- 密码设置 -->
<div v-if="activeTab === 'password'">
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 max-w-lg">
<h3 class="text-sm font-medium text-gray-200 mb-1">访问密码</h3>
<p class="text-xs text-gray-500 mb-4">团队成员需要输入此密码才能访问知识库</p>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs text-gray-400">当前状态</span>
<span v-if="hasPassword" class="text-xs text-green-400">已设置</span>
<span v-else class="text-xs text-yellow-400">未设置</span>
</div>
<div class="flex items-center gap-3">
<input v-model="newPassword" type="text" placeholder="输入新密码至少4位" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<button @click="setPassword" :disabled="!newPassword.trim() || savingPwd" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ savingPwd ? '设置中...' : '设置密码' }}
</button>
</div>
<p v-if="pwdMsg" class="text-xs mt-2" :class="pwdOk ? 'text-green-400' : 'text-red-400'">{{ pwdMsg }}</p>
</div>
</div>
<!-- 访问统计 -->
<div v-if="activeTab === 'stats'">
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 mb-4">
<h3 class="text-sm font-medium text-gray-200 mb-3">知识库概览</h3>
<div class="grid grid-cols-4 gap-4">
<div class="text-center">
<div class="text-xl font-bold text-indigo-400">{{ adminStats.total_items }}</div>
<div class="text-[11px] text-gray-500">总条目</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-emerald-400">{{ adminStats.total_categories }}</div>
<div class="text-[11px] text-gray-500">分类数</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-amber-400">{{ adminStats.total_views }}</div>
<div class="text-[11px] text-gray-500">总浏览</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-purple-400">{{ adminStats.total_ai_chats }}</div>
<div class="text-[11px] text-gray-500">AI问答</div>
</div>
</div>
</div>
<!-- 7天趋势 -->
<div v-if="adminStats.daily_trend?.length" class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h3 class="text-sm font-medium text-gray-200 mb-3">近7天趋势</h3>
<div class="space-y-2">
<div v-for="d in adminStats.daily_trend" :key="d.date" class="flex items-center gap-3 text-xs">
<span class="w-20 text-gray-500">{{ d.date.slice(5) }}</span>
<div class="flex-1 h-4 bg-gray-800 rounded-full overflow-hidden flex">
<div class="h-full bg-indigo-500/60 rounded-l" :style="{ width: barWidth(d.views, maxDaily) }"></div>
<div class="h-full bg-purple-500/60" :style="{ width: barWidth(d.ai_chats, maxDaily) }"></div>
</div>
<span class="w-16 text-right text-gray-500">{{ d.views + d.ai_chats }}</span>
</div>
</div>
<div class="flex items-center gap-4 mt-3 text-[10px] text-gray-600">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded bg-indigo-500/60"></span>浏览</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded bg-purple-500/60"></span>AI问答</span>
</div>
</div>
</div>
</div>
<!-- 帖子选择器弹窗 -->
<div v-if="showPostPicker" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" @click.self="showPostPicker = false">
<div class="bg-gray-900 border border-gray-800 rounded-xl w-[600px] max-h-[80vh] flex flex-col">
<div class="px-5 py-4 border-b border-gray-800 flex items-center justify-between">
<h3 class="text-sm font-medium text-white">从帖子添加到知识库</h3>
<button @click="showPostPicker = false" class="text-gray-500 hover:text-gray-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="px-5 py-3 border-b border-gray-800 flex items-center gap-3">
<input v-model="pickFilter.keyword" @keyup.enter="loadPostsForPick" placeholder="搜索帖子..." class="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<select v-model="pickCategoryId" class="px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none">
<option value="">添加到分类可选</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="flex-1 overflow-y-auto px-5 py-3 space-y-1">
<div v-if="pickPosts.length === 0" class="text-center py-8 text-gray-600 text-xs">
{{ pickLoading ? '加载中...' : '没有可添加的帖子' }}
</div>
<label v-for="p in pickPosts" :key="p.id" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-800/60 cursor-pointer">
<input type="checkbox" v-model="selectedPostIds" :value="p.id" class="rounded border-gray-600 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-0 bg-gray-800" />
<div class="flex-1 min-w-0">
<div class="text-xs text-gray-200 truncate">{{ p.title }}</div>
<div class="text-[10px] text-gray-500">{{ p.author_name }} · {{ formatDate(p.created_at) }}</div>
</div>
</label>
</div>
<div class="px-5 py-3 border-t border-gray-800 flex items-center justify-between">
<span class="text-xs text-gray-500">已选 {{ selectedPostIds.length }} </span>
<div class="flex gap-2">
<button @click="showPostPicker = false" class="px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200">取消</button>
<button @click="addSelectedPosts" :disabled="selectedPostIds.length === 0 || addingPosts" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-xs text-white rounded-lg transition-colors">
{{ addingPosts ? '添加中...' : '添加到知识库' }}
</button>
</div>
</div>
</div>
</div>
<!-- 编辑条目弹窗 -->
<div v-if="editingItem" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" @click.self="editingItem = null">
<div class="bg-gray-900 border border-gray-800 rounded-xl w-[440px] p-5">
<h3 class="text-sm font-medium text-white mb-4">编辑条目</h3>
<div class="space-y-3">
<div>
<label class="text-[11px] text-gray-500 mb-1 block">标题</label>
<input v-model="editingItem.title" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="text-[11px] text-gray-500 mb-1 block">分类</label>
<select v-model="editingItem.category_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-indigo-500">
<option :value="null">无分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div>
<label class="text-[11px] text-gray-500 mb-1 block">摘要</label>
<textarea v-model="editingItem.summary" rows="3" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500 resize-none"></textarea>
</div>
<div>
<label class="text-[11px] text-gray-500 mb-1 block">排序</label>
<input v-model.number="editingItem.sort_order" type="number" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
</div>
</div>
<div class="flex justify-end gap-2 mt-5">
<button @click="editingItem = null" class="px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200">取消</button>
<button @click="saveItem" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-xs text-white rounded-lg transition-colors">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { kbApi } from '../../api/modules'
const tabs = [
{ key: 'items', label: '条目管理' },
{ key: 'categories', label: '分类管理' },
{ key: 'password', label: '密码设置' },
{ key: 'stats', label: '访问统计' },
]
const activeTab = ref('items')
// --- 条目管理 ---
const items = ref([])
const itemTotal = ref(0)
const itemFilter = reactive({ category_id: '', keyword: '', page: 1, page_size: 20 })
const editingItem = ref(null)
async function loadItems() {
try {
const params = { page: itemFilter.page, page_size: itemFilter.page_size }
if (itemFilter.category_id) params.category_id = itemFilter.category_id
if (itemFilter.keyword) params.keyword = itemFilter.keyword
const { data } = await kbApi.adminGetItems(params)
items.value = data.items || data
itemTotal.value = data.total || items.value.length
} catch (e) { console.error(e) }
}
function openEditItem(item) {
editingItem.value = { ...item }
}
async function saveItem() {
if (!editingItem.value) return
try {
await kbApi.adminUpdateItem(editingItem.value.id, {
title: editingItem.value.title,
category_id: editingItem.value.category_id,
summary: editingItem.value.summary,
sort_order: editingItem.value.sort_order,
})
editingItem.value = null
loadItems()
} catch (e) { console.error(e) }
}
async function deleteItem(id) {
if (!confirm('确认删除此条目?')) return
try {
await kbApi.adminDeleteItem(id)
loadItems()
} catch (e) { console.error(e) }
}
// --- 分类管理 ---
const categories = ref([])
const newCatName = ref('')
const editingCat = ref(null)
async function loadCategories() {
try {
const { data } = await kbApi.adminGetCategories()
categories.value = data
} catch (e) { console.error(e) }
}
async function createCategory() {
if (!newCatName.value.trim()) return
try {
await kbApi.adminCreateCategory({ name: newCatName.value.trim() })
newCatName.value = ''
loadCategories()
} catch (e) { console.error(e) }
}
async function saveCategory() {
if (!editingCat.value) return
try {
await kbApi.adminUpdateCategory(editingCat.value.id, {
name: editingCat.value.name,
sort_order: editingCat.value.sort_order,
})
editingCat.value = null
loadCategories()
} catch (e) { console.error(e) }
}
async function deleteCategory(id) {
if (!confirm('确认删除此分类?条目将变为未分类')) return
try {
await kbApi.adminDeleteCategory(id)
loadCategories()
} catch (e) { console.error(e) }
}
// --- 密码设置 ---
const hasPassword = ref(false)
const newPassword = ref('')
const savingPwd = ref(false)
const pwdMsg = ref('')
const pwdOk = ref(false)
async function loadPasswordStatus() {
try {
const { data } = await kbApi.getPasswordStatus()
hasPassword.value = data.has_password
} catch (e) { /* ignore */ }
}
async function setPassword() {
if (!newPassword.value.trim() || newPassword.value.length < 4) {
pwdMsg.value = '密码至少4位'
pwdOk.value = false
return
}
savingPwd.value = true
pwdMsg.value = ''
try {
await kbApi.setPassword(newPassword.value)
hasPassword.value = true
pwdMsg.value = '密码设置成功'
pwdOk.value = true
newPassword.value = ''
} catch (e) {
pwdMsg.value = e.response?.data?.detail || '设置失败'
pwdOk.value = false
} finally { savingPwd.value = false }
}
// --- 访问统计 ---
const adminStats = ref({ total_items: 0, total_categories: 0, total_views: 0, total_ai_chats: 0, daily_trend: [] })
async function loadAdminStats() {
try {
const { data } = await kbApi.adminGetStats()
adminStats.value = data
} catch (e) { console.error(e) }
}
const maxDaily = computed(() => {
if (!adminStats.value.daily_trend?.length) return 1
return Math.max(1, ...adminStats.value.daily_trend.map(d => (d.views || 0) + (d.ai_chats || 0)))
})
function barWidth(val, max) {
return max > 0 ? `${((val || 0) / max) * 100}%` : '0%'
}
// --- 帖子选择器 ---
const showPostPicker = ref(false)
const pickPosts = ref([])
const pickLoading = ref(false)
const pickFilter = reactive({ keyword: '' })
const pickCategoryId = ref('')
const selectedPostIds = ref([])
const addingPosts = ref(false)
async function loadPostsForPick() {
pickLoading.value = true
try {
const params = {}
if (pickFilter.keyword) params.keyword = pickFilter.keyword
const { data } = await kbApi.adminGetPostsForPick(params)
pickPosts.value = data.items || data || []
} catch (e) { console.error(e) }
finally { pickLoading.value = false }
}
async function addSelectedPosts() {
if (selectedPostIds.value.length === 0) return
addingPosts.value = true
try {
await kbApi.adminAddItems({
post_ids: selectedPostIds.value,
category_id: pickCategoryId.value || null,
})
showPostPicker.value = false
selectedPostIds.value = []
pickCategoryId.value = ''
loadItems()
} catch (e) { console.error(e) }
finally { addingPosts.value = false }
}
watch(showPostPicker, (v) => {
if (v) {
pickFilter.keyword = ''
selectedPostIds.value = []
loadPostsForPick()
}
})
// --- 工具函数 ---
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
}
// --- Tab 切换时加载数据 ---
watch(activeTab, (t) => {
if (t === 'items') loadItems()
else if (t === 'categories') loadCategories()
else if (t === 'password') loadPasswordStatus()
else if (t === 'stats') loadAdminStats()
})
onMounted(() => {
loadCategories()
loadItems()
loadPasswordStatus()
})
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div class="h-screen bg-gray-950 text-gray-100 flex overflow-hidden">
<!-- 左侧管理侧边栏 -->
<aside class="w-[180px] bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
<!-- 顶部 -->
<div class="px-4 py-4 border-b border-gray-800">
<div class="flex items-center justify-between">
<span class="text-sm font-bold text-white">管理后台</span>
<router-link to="/" class="p-1 text-gray-500 hover:text-indigo-400 rounded transition-colors" title="返回前台">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</router-link>
</div>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 py-3 px-2 space-y-0.5">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors"
:class="isActive(item.path, item.exact) ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>
<div class="w-4 h-4 shrink-0" v-html="item.icon"></div>
<span>{{ item.label }}</span>
</router-link>
</nav>
<!-- 底部管理员信息 -->
<div class="border-t border-gray-800 px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-indigo-600/30 flex items-center justify-center text-[10px] font-bold text-indigo-400">
{{ userStore.user?.username?.charAt(0)?.toUpperCase() || 'A' }}
</div>
<div class="min-w-0">
<div class="text-xs text-gray-300 truncate">{{ userStore.user?.username }}</div>
<div class="text-[10px] text-gray-600">管理员</div>
</div>
</div>
</div>
</aside>
<!-- 主内容 -->
<main class="flex-1 overflow-hidden">
<router-view />
</main>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
import { useUserStore } from '../../stores/user'
const route = useRoute()
const userStore = useUserStore()
const menuItems = [
{
path: '/admin', label: '数据概览', exact: true,
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>',
},
{
path: '/admin/users', label: '用户管理',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>',
},
{
path: '/admin/posts', label: '内容管理',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>',
},
{
path: '/admin/categories', label: '分类管理',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>',
},
{
path: '/admin/models', label: '模型管理',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>',
},
{
path: '/admin/storage', label: '对象存储',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"/></svg>',
},
{
path: '/admin/nav', label: '导航管理',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>',
},
{
path: '/admin/projects', label: '项目管理',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>',
},
{
path: '/admin/api-hub', label: 'API管理',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>',
},
{
path: '/admin/kb', label: '知识库',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>',
},
]
function isActive(path, exact = false) {
if (exact) return route.path === path
return route.path.startsWith(path)
}
</script>

View File

@@ -0,0 +1,450 @@
<template>
<div class="h-full overflow-y-auto p-6">
<div class="max-w-5xl">
<!-- 标题 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-bold text-white">导航管理</h1>
<p class="text-xs text-gray-500 mt-1">管理公共导航站的分类和链接</p>
</div>
<div class="flex items-center gap-3">
<!-- 直接添加链接 -->
<button @click="openGlobalLinkModal" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors">+ 添加链接</button>
<!-- Tab 切换 -->
<div class="flex items-center gap-1 bg-gray-900 border border-gray-800 rounded-lg p-0.5">
<button @click="activeTab = 'manage'" :class="activeTab === 'manage' ? 'bg-gray-800 text-gray-200' : 'text-gray-500 hover:text-gray-300'" class="px-3 py-1.5 text-xs rounded-md transition-colors">分类与链接</button>
<button @click="activeTab = 'review'; loadPendingLinks()" :class="activeTab === 'review' ? 'bg-gray-800 text-gray-200' : 'text-gray-500 hover:text-gray-300'" class="px-3 py-1.5 text-xs rounded-md transition-colors flex items-center gap-1.5">
待审核
<span v-if="pendingCount > 0" class="px-1.5 py-0.5 bg-red-600 text-white text-[10px] rounded-full leading-none">{{ pendingCount }}</span>
</button>
</div>
</div>
</div>
<!-- 左右分栏 -->
<div v-if="activeTab === 'manage'" class="flex gap-6">
<!-- 左侧分类列表 -->
<div class="w-72 shrink-0">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-300">分类</h3>
<button @click="showAddCat = true" class="px-2.5 py-1 bg-indigo-600 hover:bg-indigo-500 text-white text-[11px] rounded-lg transition-colors">+ 新增</button>
</div>
<!-- 新增分类 -->
<div v-if="showAddCat" class="bg-gray-900 border border-gray-800 rounded-xl p-3 mb-3 space-y-2">
<input v-model="newCatName" @keydown.enter="addCategory" @keydown.escape="showAddCat = false" placeholder="分类名称" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<input v-model="newCatIcon" placeholder="图标URL可选" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<div class="flex gap-2">
<button @click="addCategory" :disabled="!newCatName.trim()" class="flex-1 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors">确定</button>
<button @click="showAddCat = false; newCatName = ''; newCatIcon = ''" class="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300">取消</button>
</div>
<p v-if="catError" class="text-xs text-red-400">{{ catError }}</p>
</div>
<!-- 分类列表 -->
<div v-if="catsLoading" class="flex justify-center py-8">
<div class="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-else class="space-y-1">
<div
v-for="(cat, index) in categories"
:key="cat.id"
@click="selectCategory(cat)"
class="flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-colors group"
:class="selectedCat?.id === cat.id ? 'bg-indigo-600/20 border border-indigo-600/30' : 'bg-gray-900 border border-gray-800 hover:border-gray-700'"
>
<!-- 排序 -->
<div class="flex flex-col gap-0 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click.stop="moveCat(index, -1)" :disabled="index === 0" class="p-0.5 text-gray-600 hover:text-gray-300 disabled:opacity-20"><svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg></button>
<button @click.stop="moveCat(index, 1)" :disabled="index === categories.length - 1" class="p-0.5 text-gray-600 hover:text-gray-300 disabled:opacity-20"><svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg></button>
</div>
<!-- 名称 -->
<div class="flex-1 min-w-0">
<span class="text-sm text-gray-200 truncate block">{{ cat.name }}</span>
<span class="text-[10px] text-gray-600">{{ cat.link_count }} 个链接</span>
</div>
<!-- 状态 -->
<span
@click.stop="toggleCatActive(cat)"
class="px-1.5 py-0.5 text-[10px] rounded cursor-pointer shrink-0"
:class="cat.is_active ? 'bg-green-900/40 text-green-400' : 'bg-gray-800 text-gray-500'"
>{{ cat.is_active ? '启用' : '禁用' }}</span>
<!-- 删除 -->
<button @click.stop="deleteCategory(cat)" class="p-0.5 text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all shrink-0">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
<p v-if="categories.length === 0" class="text-center text-gray-600 text-xs py-8">暂无分类</p>
</div>
</div>
<!-- 右侧链接列表 -->
<div class="flex-1 min-w-0">
<template v-if="selectedCat">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-300">{{ selectedCat.name }} · 链接</h3>
<button @click="openLinkModal()" class="px-2.5 py-1 bg-indigo-600 hover:bg-indigo-500 text-white text-[11px] rounded-lg transition-colors">+ 添加链接</button>
</div>
<div v-if="linksLoading" class="flex justify-center py-8">
<div class="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-else class="space-y-1.5">
<div
v-for="(link, index) in links"
:key="link.id"
class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3 flex items-center gap-3 group hover:border-gray-700 transition-colors"
>
<!-- 排序 -->
<div class="flex flex-col gap-0 shrink-0">
<button @click="moveLink(index, -1)" :disabled="index === 0" class="p-0.5 text-gray-600 hover:text-gray-300 disabled:opacity-20"><svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg></button>
<button @click="moveLink(index, 1)" :disabled="index === links.length - 1" class="p-0.5 text-gray-600 hover:text-gray-300 disabled:opacity-20"><svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg></button>
</div>
<!-- 图标 -->
<div class="w-8 h-8 rounded-lg bg-gray-800 flex items-center justify-center shrink-0">
<img v-if="link.icon" :src="link.icon" class="w-5 h-5 rounded" @error="$event.target.style.display='none'" />
<span v-else class="text-xs font-bold text-indigo-400">{{ link.name.charAt(0).toUpperCase() }}</span>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-200">{{ link.name }}</div>
<div class="text-[11px] text-gray-600 truncate">{{ link.url }}</div>
<div v-if="link.description" class="text-[11px] text-gray-500 truncate mt-0.5">{{ link.description }}</div>
</div>
<!-- 状态 -->
<span
@click="toggleLinkActive(link)"
class="px-1.5 py-0.5 text-[10px] rounded cursor-pointer shrink-0"
:class="link.is_active ? 'bg-green-900/40 text-green-400' : 'bg-gray-800 text-gray-500'"
>{{ link.is_active ? '启用' : '禁用' }}</span>
<!-- 操作 -->
<div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click="openLinkModal(link)" class="p-1 text-gray-500 hover:text-indigo-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="deleteLink(link)" class="p-1 text-gray-500 hover:text-red-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
<p v-if="links.length === 0" class="text-center text-gray-600 text-xs py-8">该分类下暂无链接</p>
</div>
</template>
<div v-else class="flex items-center justify-center h-full">
<p class="text-sm text-gray-600">请从左侧选择一个分类</p>
</div>
</div>
</div>
<!-- 审核面板 -->
<div v-if="activeTab === 'review'">
<div v-if="pendingLoading" class="flex justify-center py-12">
<div class="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-else-if="pendingLinks.length === 0" class="text-center py-16">
<svg class="w-12 h-12 text-gray-700 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<p class="text-sm text-gray-500">没有待审核的提交</p>
</div>
<div v-else class="space-y-2">
<div v-for="link in pendingLinks" :key="link.id" class="bg-gray-900 border border-gray-800 rounded-xl px-5 py-4 flex items-start gap-4">
<!-- 图标 -->
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center shrink-0">
<img v-if="link.icon" :src="link.icon" class="w-6 h-6 rounded" @error="$event.target.style.display='none'" />
<span v-else class="text-sm font-bold text-indigo-400">{{ link.name.charAt(0).toUpperCase() }}</span>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-200 font-medium">{{ link.name }}</div>
<a :href="link.url" target="_blank" class="text-[11px] text-indigo-400 hover:underline truncate block">{{ link.url }}</a>
<div v-if="link.description" class="text-[11px] text-gray-500 mt-0.5">{{ link.description }}</div>
<div class="text-[10px] text-gray-600 mt-1">提交者 ID: {{ link.submitted_by }} · 分类: {{ getCatName(link.category_id) }}</div>
</div>
<!-- 审核操作 -->
<div class="flex items-center gap-2 shrink-0">
<button @click="reviewLink(link.id, 'approve')" class="px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs rounded-lg transition-colors">通过</button>
<button @click="openRejectModal(link)" class="px-3 py-1.5 bg-red-600/80 hover:bg-red-600 text-white text-xs rounded-lg transition-colors">拒绝</button>
</div>
</div>
</div>
</div>
</div>
<!-- 拒绝原因弹窗 -->
<Teleport to="body">
<div v-if="showRejectModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showRejectModal = false">
<div class="w-full max-w-sm bg-gray-900 border border-gray-800 rounded-xl p-5 mx-4">
<h3 class="text-sm font-bold text-gray-100 mb-3">拒绝理由</h3>
<input v-model="rejectReason" placeholder="请输入拒绝原因(可选)" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<div class="flex justify-end gap-2 mt-4">
<button @click="showRejectModal = false" class="px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200">取消</button>
<button @click="confirmReject" class="px-4 py-1.5 bg-red-600 hover:bg-red-500 text-white text-xs rounded-lg transition-colors">确认拒绝</button>
</div>
</div>
</div>
</Teleport>
<!-- 链接编辑弹窗 -->
<Teleport to="body">
<div v-if="showLinkModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showLinkModal = false">
<div class="w-full max-w-md bg-gray-900 border border-gray-800 rounded-xl p-6 mx-4">
<h3 class="text-base font-bold text-gray-100 mb-4">{{ editingLink ? '编辑链接' : '添加链接' }}</h3>
<div class="space-y-3">
<!-- 分类选择全局添加时显示 -->
<div v-if="!editingLink && !selectedCat">
<label class="block text-xs text-gray-400 mb-1">分类 <span class="text-red-400">*</span></label>
<select v-model="linkFormCategoryId" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500">
<option :value="0" disabled>请选择分类</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">名称 <span class="text-red-400">*</span></label>
<input v-model="linkForm.name" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="网站名称" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">URL <span class="text-red-400">*</span></label>
<input v-model="linkForm.url" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="https://..." />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">图标URL</label>
<input v-model="linkForm.icon" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="https://example.com/favicon.ico" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">描述</label>
<input v-model="linkForm.description" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="简短描述(可选)" />
</div>
</div>
<p v-if="linkError" class="text-xs text-red-400 mt-2">{{ linkError }}</p>
<div class="flex justify-end gap-3 mt-5">
<button @click="showLinkModal = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-colors">取消</button>
<button @click="saveLink" :disabled="!linkForm.name.trim() || !linkForm.url.trim() || linkSaving || (!editingLink && !selectedCat && !linkFormCategoryId)" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ linkSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { navApi } from '../../api/modules'
const activeTab = ref('manage')
// === 分类 ===
const categories = ref([])
const catsLoading = ref(true)
const selectedCat = ref(null)
const showAddCat = ref(false)
const newCatName = ref('')
const newCatIcon = ref('')
const catError = ref('')
// === 链接 ===
const links = ref([])
const linksLoading = ref(false)
const showLinkModal = ref(false)
const editingLink = ref(null)
const linkForm = ref({ name: '', url: '', icon: '', description: '' })
const linkFormCategoryId = ref(0)
const linkError = ref('')
const linkSaving = ref(false)
onMounted(() => {
loadCategories()
loadPendingCount()
})
async function loadCategories() {
catsLoading.value = true
try {
const { data } = await navApi.getCategories()
categories.value = data
// 保持选中状态
if (selectedCat.value) {
const still = data.find(c => c.id === selectedCat.value.id)
if (still) selectedCat.value = still
else selectedCat.value = null
}
} catch (e) { console.error(e) }
finally { catsLoading.value = false }
}
async function addCategory() {
if (!newCatName.value.trim()) return
catError.value = ''
try {
await navApi.createCategory({ name: newCatName.value.trim(), icon: newCatIcon.value.trim() })
newCatName.value = ''
newCatIcon.value = ''
showAddCat.value = false
await loadCategories()
} catch (e) {
catError.value = e.response?.data?.detail || '添加失败'
}
}
async function toggleCatActive(cat) {
try {
await navApi.updateCategory(cat.id, { is_active: !cat.is_active })
await loadCategories()
} catch (e) { console.error(e) }
}
async function deleteCategory(cat) {
if (!confirm(`确定删除分类「${cat.name}」及其下所有链接?`)) return
try {
await navApi.deleteCategory(cat.id)
if (selectedCat.value?.id === cat.id) selectedCat.value = null
await loadCategories()
} catch (e) { alert(e.response?.data?.detail || '删除失败') }
}
async function moveCat(index, dir) {
const newIndex = index + dir
if (newIndex < 0 || newIndex >= categories.value.length) return
const items = [...categories.value]
;[items[index], items[newIndex]] = [items[newIndex], items[index]]
categories.value = items
for (let i = 0; i < items.length; i++) {
await navApi.updateCategory(items[i].id, { sort_order: i })
}
await loadCategories()
}
function selectCategory(cat) {
selectedCat.value = cat
loadLinks()
}
async function loadLinks() {
if (!selectedCat.value) return
linksLoading.value = true
try {
const { data } = await navApi.getLinks({ category_id: selectedCat.value.id })
links.value = data
} catch (e) { links.value = [] }
finally { linksLoading.value = false }
}
function openLinkModal(link = null) {
editingLink.value = link
linkForm.value = link
? { name: link.name, url: link.url, icon: link.icon || '', description: link.description || '' }
: { name: '', url: '', icon: '', description: '' }
linkFormCategoryId.value = 0
linkError.value = ''
showLinkModal.value = true
}
function openGlobalLinkModal() {
editingLink.value = null
linkForm.value = { name: '', url: '', icon: '', description: '' }
linkFormCategoryId.value = 0
linkError.value = ''
showLinkModal.value = true
}
async function saveLink() {
if (!linkForm.value.name.trim() || !linkForm.value.url.trim()) return
linkError.value = ''
linkSaving.value = true
try {
let url = linkForm.value.url.trim()
if (!/^https?:\/\//.test(url)) url = 'https://' + url
const payload = { ...linkForm.value, url }
if (editingLink.value) {
await navApi.updateLink(editingLink.value.id, payload)
} else {
const catId = selectedCat.value ? selectedCat.value.id : linkFormCategoryId.value
if (!catId) { linkError.value = '请选择分类'; linkSaving.value = false; return }
await navApi.createLink({ ...payload, category_id: catId })
}
showLinkModal.value = false
await loadLinks()
await loadCategories()
} catch (e) {
linkError.value = e.response?.data?.detail || '保存失败'
} finally { linkSaving.value = false }
}
async function toggleLinkActive(link) {
try {
await navApi.updateLink(link.id, { is_active: !link.is_active })
await loadLinks()
} catch (e) { console.error(e) }
}
async function deleteLink(link) {
if (!confirm(`确定删除链接「${link.name}」?`)) return
try {
await navApi.deleteLink(link.id)
await loadLinks()
await loadCategories()
} catch (e) { alert(e.response?.data?.detail || '删除失败') }
}
async function moveLink(index, dir) {
const newIndex = index + dir
if (newIndex < 0 || newIndex >= links.value.length) return
const items = [...links.value]
;[items[index], items[newIndex]] = [items[newIndex], items[index]]
links.value = items
for (let i = 0; i < items.length; i++) {
await navApi.updateLink(items[i].id, { sort_order: i })
}
await loadLinks()
}
// === 审核 ===
const pendingCount = ref(0)
const pendingLinks = ref([])
const pendingLoading = ref(false)
const showRejectModal = ref(false)
const rejectReason = ref('')
const rejectingLink = ref(null)
async function loadPendingCount() {
try {
const { data } = await navApi.getPendingCount()
pendingCount.value = data.count
} catch (e) { /* 忽略 */ }
}
async function loadPendingLinks() {
pendingLoading.value = true
try {
const { data } = await navApi.getLinks({ status: 'pending' })
pendingLinks.value = data
pendingCount.value = data.length
} catch (e) { pendingLinks.value = [] }
finally { pendingLoading.value = false }
}
function getCatName(catId) {
const cat = categories.value.find(c => c.id === catId)
return cat ? cat.name : '未知'
}
async function reviewLink(linkId, action, reason = '') {
try {
await navApi.reviewLink(linkId, { action, reject_reason: reason })
await loadPendingLinks()
await loadCategories()
} catch (e) { alert(e.response?.data?.detail || '审核失败') }
}
function openRejectModal(link) {
rejectingLink.value = link
rejectReason.value = ''
showRejectModal.value = true
}
async function confirmReject() {
if (!rejectingLink.value) return
await reviewLink(rejectingLink.value.id, 'reject', rejectReason.value)
showRejectModal.value = false
}
</script>

View File

@@ -0,0 +1,137 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-white">内容管理</h1>
<span class="text-sm text-gray-500"> {{ total }} 篇文章</span>
</div>
<!-- 搜索 -->
<div class="mb-4">
<input
v-model="search"
@input="debouncedFetch"
placeholder="搜索文章标题..."
class="w-72 px-4 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
</div>
<!-- 表格 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table class="w-full">
<thead class="bg-gray-800/50">
<tr>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">标题</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">作者</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">分类</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">点赞</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">评论</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">浏览</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">状态</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">发布时间</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in posts" :key="p.id" class="border-t border-gray-800/50 hover:bg-gray-800/30">
<td class="px-4 py-3">
<div class="text-sm text-gray-200 truncate max-w-[240px]">{{ p.title }}</div>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ p.author }}</td>
<td class="px-4 py-3">
<span v-if="p.category" class="text-[10px] bg-gray-700/50 text-gray-400 px-1.5 py-0.5 rounded">{{ p.category }}</span>
<span v-else class="text-gray-600">-</span>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ p.like_count }}</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ p.comment_count }}</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ p.view_count }}</td>
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded"
:class="p.is_public ? 'bg-green-500/20 text-green-400' : 'bg-gray-700/50 text-gray-500'"
>
{{ p.is_public ? '公开' : '私密' }}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ formatDate(p.created_at) }}</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<router-link :to="`/post/${p.id}`" class="text-xs text-indigo-400 hover:text-indigo-300">查看</router-link>
<router-link :to="`/post/edit/${p.id}`" class="text-xs text-emerald-400 hover:text-emerald-300">编辑</router-link>
<button @click="deletePost(p)" class="text-xs text-red-400 hover:text-red-300">删除</button>
</div>
</td>
</tr>
<tr v-if="posts.length === 0 && !loading">
<td colspan="9" class="px-4 py-12 text-center text-gray-600 text-sm">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center gap-1 mt-4">
<button
v-for="p in totalPages"
:key="p"
@click="page = p; fetchPosts()"
class="w-8 h-8 rounded text-xs transition-colors"
:class="page === p ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'"
>
{{ p }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { adminApi } from '../../api/modules'
const posts = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const search = ref('')
const loading = ref(false)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
let debounceTimer = null
function debouncedFetch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => { page.value = 1; fetchPosts() }, 300)
}
async function fetchPosts() {
loading.value = true
try {
const res = await adminApi.getPosts({ search: search.value, page: page.value, page_size: pageSize })
posts.value = res.data.items
total.value = res.data.total
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function deletePost(p) {
if (!confirm(`确定删除文章「${p.title}」?此操作不可恢复。`)) return
try {
await adminApi.deletePost(p.id)
posts.value = posts.value.filter(x => x.id !== p.id)
total.value--
} catch (e) {
alert(e.response?.data?.detail || '删除失败')
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN')
}
onMounted(() => fetchPosts())
</script>

View File

@@ -0,0 +1,532 @@
<template>
<div class="h-full overflow-y-auto p-6">
<div class="max-w-5xl">
<!-- 标题 -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-bold text-white">项目管理</h1>
<p class="text-xs text-gray-500 mt-1">管理开源项目展示数据</p>
</div>
<div class="flex items-center gap-2">
<button @click="showGithubModal = true" class="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-gray-300 text-xs rounded-lg transition-colors border border-gray-700 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub 导入
</button>
<button @click="openModal()" class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors">+ 新增项目</button>
</div>
</div>
<!-- 筛选 -->
<div class="flex items-center gap-3 mb-4">
<input v-model="keyword" @keydown.enter="loadProjects" placeholder="搜索项目名称..." class="w-56 px-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<select v-model="filterCat" @change="loadProjects" class="px-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500">
<option value="">全部分类</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
<span class="text-xs text-gray-500"> {{ total }} 个项目</span>
</div>
<!-- 加载 -->
<div v-if="loading" class="flex justify-center py-16">
<div class="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- 列表 -->
<div v-else class="space-y-1.5">
<div
v-for="proj in projects"
:key="proj.id"
class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3 flex items-center gap-4 group hover:border-gray-700 transition-colors"
>
<!-- 图标 -->
<div class="w-9 h-9 rounded-lg bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0">
<img v-if="proj.icon" :src="proj.icon" class="w-5 h-5 rounded" @error="$event.target.style.display='none'" />
<span v-else class="text-sm font-bold text-indigo-400">{{ proj.name.charAt(0).toUpperCase() }}</span>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-200 font-medium truncate">{{ proj.name }}</div>
<div class="text-[11px] text-gray-500 truncate mt-0.5">{{ proj.url }}</div>
</div>
<!-- 语言 -->
<span v-if="proj.language" class="px-2 py-0.5 bg-gray-800 text-[10px] text-gray-400 rounded shrink-0">{{ proj.language }}</span>
<!-- 分类 -->
<span v-if="proj.category" class="px-2 py-0.5 bg-indigo-900/30 text-[10px] text-indigo-400 rounded shrink-0">{{ proj.category }}</span>
<!-- Star/Fork -->
<div class="flex items-center gap-3 shrink-0 text-[11px] text-gray-500">
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ proj.stars }}
</span>
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></svg>
{{ proj.forks }}
</span>
</div>
<!-- 状态 -->
<span
@click="toggleActive(proj)"
class="px-1.5 py-0.5 text-[10px] rounded cursor-pointer shrink-0"
:class="proj.is_active ? 'bg-green-900/40 text-green-400' : 'bg-gray-800 text-gray-500'"
>{{ proj.is_active ? '启用' : '禁用' }}</span>
<!-- 操作 -->
<div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click="openModal(proj)" class="p-1 text-gray-500 hover:text-indigo-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="deleteProject(proj)" class="p-1 text-gray-500 hover:text-red-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</div>
<p v-if="projects.length === 0" class="text-center text-gray-600 text-xs py-12">暂无项目数据</p>
</div>
<!-- 分页 -->
<div v-if="total > pageSize" class="flex items-center justify-center gap-2 mt-6">
<button @click="page > 1 && (page--, loadProjects())" :disabled="page <= 1" class="px-3 py-1.5 text-xs bg-gray-900 border border-gray-800 rounded-lg text-gray-400 hover:text-gray-200 disabled:opacity-30">上一页</button>
<span class="text-xs text-gray-500">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadProjects())" :disabled="page >= Math.ceil(total / pageSize)" class="px-3 py-1.5 text-xs bg-gray-900 border border-gray-800 rounded-lg text-gray-400 hover:text-gray-200 disabled:opacity-30">下一页</button>
</div>
</div>
<!-- 编辑弹窗 -->
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showModal = false">
<div class="w-full max-w-lg bg-gray-900 border border-gray-800 rounded-xl p-6 mx-4 max-h-[85vh] overflow-y-auto">
<h3 class="text-base font-bold text-gray-100 mb-4">{{ editing ? '编辑项目' : '新增项目' }}</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">项目名称 <span class="text-red-400">*</span></label>
<input v-model="form.name" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="如 Vue.js" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">主要语言</label>
<input v-model="form.language" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="如 JavaScript" />
</div>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">GitHub/GitLab 地址 <span class="text-red-400">*</span></label>
<input v-model="form.url" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="https://github.com/..." />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">项目描述</label>
<textarea v-model="form.description" rows="2" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500 resize-none" placeholder="简要描述项目功能"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">分类</label>
<select v-model="form.category" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500">
<option value="">无分类</option>
<option v-for="c in allCategories" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">自定义分类</label>
<input v-model="customCategory" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="输入新分类名" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">Star </label>
<input v-model.number="form.stars" type="number" min="0" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">Fork </label>
<input v-model.number="form.forks" type="number" min="0" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 focus:outline-none focus:border-indigo-500" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-400 mb-1">图标 URL</label>
<input v-model="form.icon" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="https://..." />
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">官网 URL</label>
<input v-model="form.homepage" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" placeholder="https://..." />
</div>
</div>
</div>
<p v-if="formError" class="text-xs text-red-400 mt-2">{{ formError }}</p>
<div class="flex justify-end gap-3 mt-5">
<button @click="showModal = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-colors">取消</button>
<button @click="saveProject" :disabled="!form.name.trim() || !form.url.trim() || saving" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- GitHub 导入弹窗 -->
<Teleport to="body">
<div v-if="showGithubModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="showGithubModal = false">
<div class="w-full max-w-2xl bg-gray-900 border border-gray-800 rounded-xl p-6 mx-4 max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-gray-100 flex items-center gap-2">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub 导入
</h3>
<button @click="showGithubModal = false" class="p-1 text-gray-500 hover:text-gray-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 搜索栏 -->
<div class="flex items-center gap-2 mb-3">
<input v-model="ghKeyword" @keydown.enter="buildAndSearch" placeholder="输入关键词搜索,如 vue、react、todo app..." class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500" />
<button @click="buildAndSearch" :disabled="ghSearching" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors shrink-0">
{{ ghSearching ? '搜索中...' : '搜索' }}
</button>
</div>
<!-- 筛选条件 -->
<div class="grid grid-cols-4 gap-2 mb-3">
<div>
<label class="block text-[10px] text-gray-500 mb-1">最低 Star</label>
<select v-model="ghMinStars" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="">不限</option>
<option value="100">> 100</option>
<option value="1000">> 1,000</option>
<option value="5000">> 5,000</option>
<option value="10000">> 10,000</option>
<option value="50000">> 50,000</option>
</select>
</div>
<div>
<label class="block text-[10px] text-gray-500 mb-1">编程语言</label>
<select v-model="ghLang" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="">全部语言</option>
<option v-for="l in langOptions" :key="l" :value="l">{{ l }}</option>
</select>
</div>
<div>
<label class="block text-[10px] text-gray-500 mb-1">排序方式</label>
<select v-model="ghSort" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="stars">Star </option>
<option value="forks">Fork </option>
<option value="updated">最近更新</option>
</select>
</div>
<div>
<label class="block text-[10px] text-gray-500 mb-1">导入到分类</label>
<select v-model="ghImportCat" class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-indigo-500">
<option value="">自动识别</option>
<option v-for="c in allCategories" :key="c" :value="c">{{ c }}</option>
</select>
</div>
</div>
<!-- 快捷搜索 -->
<div class="flex flex-wrap gap-1.5 mb-3">
<span class="text-[10px] text-gray-600 leading-5">快捷</span>
<button v-for="preset in quickPresets" :key="preset.label" @click="applyPreset(preset)" class="px-2 py-0.5 bg-gray-800 border border-gray-700 text-[10px] text-gray-400 rounded hover:text-indigo-400 hover:border-indigo-600/30 transition-colors">{{ preset.label }}</button>
</div>
<p v-if="ghError" class="text-xs text-red-400 mb-2">{{ ghError }}</p>
<!-- 搜索结果 -->
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="ghSearching" class="flex justify-center py-10">
<div class="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-else-if="ghResults.length > 0" class="space-y-1.5">
<div
v-for="repo in ghResults"
:key="repo.github_id"
@click="toggleSelect(repo)"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-colors"
:class="ghSelected.has(repo.url) ? 'bg-indigo-600/20 border border-indigo-600/30' : 'bg-gray-800/50 border border-gray-800 hover:border-gray-700'"
>
<!-- 勾选 -->
<div class="w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors" :class="ghSelected.has(repo.url) ? 'bg-indigo-600 border-indigo-600' : 'border-gray-600'">
<svg v-if="ghSelected.has(repo.url)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>
</div>
<!-- 图标 -->
<img :src="repo.icon" class="w-7 h-7 rounded-lg shrink-0" @error="$event.target.style.display='none'" />
<!-- 信息 -->
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-200 font-medium truncate">{{ repo.full_name }}</div>
<div class="text-[11px] text-gray-500 truncate">{{ repo.description }}</div>
</div>
<!-- 语言 -->
<span v-if="repo.language" class="px-2 py-0.5 bg-gray-800 text-[10px] text-gray-400 rounded shrink-0">{{ repo.language }}</span>
<!-- Star/Fork -->
<div class="flex items-center gap-2 shrink-0 text-[11px] text-gray-500">
<span class="flex items-center gap-0.5">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ formatK(repo.stars) }}
</span>
<span class="flex items-center gap-0.5">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></svg>
{{ formatK(repo.forks) }}
</span>
</div>
</div>
<!-- GitHub 分页 -->
<div v-if="ghTotal > ghResults.length" class="flex justify-center pt-2">
<button @click="loadMoreGithub" :disabled="ghSearching" class="text-xs text-indigo-400 hover:text-indigo-300">加载更多</button>
</div>
</div>
<div v-else-if="ghSearched" class="text-center py-10">
<p class="text-xs text-gray-600">没有找到匹配的项目</p>
</div>
</div>
<!-- 底部操作 -->
<div v-if="ghSelected.size > 0" class="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
<span class="text-xs text-gray-400">已选择 <span class="text-indigo-400 font-medium">{{ ghSelected.size }}</span> 个项目</span>
<div class="flex items-center gap-2">
<button @click="ghSelected.clear()" class="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300">清空选择</button>
<button @click="importSelected" :disabled="ghImporting" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
{{ ghImporting ? '导入中...' : `导入 ${ghSelected.size} 个项目` }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { projectsApi } from '../../api/modules'
const projects = ref([])
const loading = ref(true)
const total = ref(0)
const page = ref(1)
const pageSize = 20
const keyword = ref('')
const filterCat = ref('')
const categoryOptions = ref([])
const allCategories = ['前端框架', '后端框架', '工具库', 'AI 相关', '数据库', '移动端', 'DevOps', '其他']
// 弹窗
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const formError = ref('')
const customCategory = ref('')
const form = ref({ name: '', url: '', description: '', language: '', category: '', stars: 0, forks: 0, icon: '', homepage: '' })
onMounted(() => {
loadProjects()
})
async function loadProjects() {
loading.value = true
try {
const params = { page: page.value, size: pageSize }
if (keyword.value.trim()) params.keyword = keyword.value.trim()
if (filterCat.value) params.category = filterCat.value
const res = await projectsApi.adminList(params)
projects.value = res.data.items
total.value = res.data.total
// 收集已有分类
const cats = new Set(categoryOptions.value)
projects.value.forEach(p => { if (p.category) cats.add(p.category) })
allCategories.forEach(c => cats.add(c))
categoryOptions.value = [...cats].sort()
} catch (e) { console.error(e) }
finally { loading.value = false }
}
function openModal(proj = null) {
editing.value = proj
customCategory.value = ''
formError.value = ''
if (proj) {
form.value = {
name: proj.name, url: proj.url, description: proj.description || '',
language: proj.language || '', category: proj.category || '',
stars: proj.stars || 0, forks: proj.forks || 0,
icon: proj.icon || '', homepage: proj.homepage || '',
}
} else {
form.value = { name: '', url: '', description: '', language: '', category: '', stars: 0, forks: 0, icon: '', homepage: '' }
}
showModal.value = true
}
async function saveProject() {
if (!form.value.name.trim() || !form.value.url.trim()) return
formError.value = ''
saving.value = true
try {
let url = form.value.url.trim()
if (!/^https?:\/\//.test(url)) url = 'https://' + url
const category = customCategory.value.trim() || form.value.category
const payload = { ...form.value, url, category }
delete payload.category
payload.category = category
if (editing.value) {
await projectsApi.adminUpdate(editing.value.id, payload)
} else {
await projectsApi.adminCreate(payload)
}
showModal.value = false
await loadProjects()
} catch (e) {
formError.value = e.response?.data?.detail || '保存失败'
} finally { saving.value = false }
}
async function toggleActive(proj) {
try {
await projectsApi.adminUpdate(proj.id, { is_active: !proj.is_active })
await loadProjects()
} catch (e) { console.error(e) }
}
async function deleteProject(proj) {
if (!confirm(`确定删除项目「${proj.name}」?`)) return
try {
await projectsApi.adminDelete(proj.id)
await loadProjects()
} catch (e) { alert(e.response?.data?.detail || '删除失败') }
}
// ========== GitHub 导入 ==========
const showGithubModal = ref(false)
const ghKeyword = ref('')
const ghMinStars = ref('1000')
const ghLang = ref('')
const ghSort = ref('stars')
const ghImportCat = ref('')
const ghResults = ref([])
const ghTotal = ref(0)
const ghPage = ref(1)
const ghSearching = ref(false)
const ghSearched = ref(false)
const ghError = ref('')
const ghSelected = ref(new Set())
const ghSelectedData = ref(new Map())
const ghImporting = ref(false)
const langOptions = ['JavaScript', 'TypeScript', 'Python', 'Java', 'Go', 'Rust', 'C++', 'C', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin', 'Dart', 'Shell', 'HTML', 'CSS', 'Vue', 'Jupyter Notebook']
const quickPresets = [
{ label: '🔥 全球热门 Top', keyword: '', stars: '50000', lang: '', sort: 'stars' },
{ label: '🆕 最近创建的热门项目', keyword: 'created:>2025-01-01', stars: '1000', lang: '', sort: 'stars' },
{ label: '⚙️ 前端框架', keyword: 'topic:frontend-framework', stars: '5000', lang: 'JavaScript', sort: 'stars' },
{ label: '🛠️ 后端框架', keyword: 'topic:backend framework', stars: '5000', lang: '', sort: 'stars' },
{ label: '🤖 AI / ML', keyword: 'topic:machine-learning OR topic:artificial-intelligence OR topic:deep-learning', stars: '5000', lang: '', sort: 'stars' },
{ label: '📦 工具库', keyword: 'topic:tool OR topic:utility OR topic:cli', stars: '5000', lang: '', sort: 'stars' },
{ label: 'Vue 生态', keyword: 'topic:vue', stars: '1000', lang: '', sort: 'stars' },
{ label: 'React 生态', keyword: 'topic:react', stars: '1000', lang: '', sort: 'stars' },
{ label: 'Python 热门', keyword: '', stars: '10000', lang: 'Python', sort: 'stars' },
{ label: 'Go 热门', keyword: '', stars: '5000', lang: 'Go', sort: 'stars' },
{ label: 'Rust 热门', keyword: '', stars: '3000', lang: 'Rust', sort: 'stars' },
{ label: 'TypeScript 热门', keyword: '', stars: '10000', lang: 'TypeScript', sort: 'stars' },
]
function applyPreset(preset) {
ghKeyword.value = preset.keyword
ghMinStars.value = preset.stars
ghLang.value = preset.lang
ghSort.value = preset.sort
buildAndSearch()
}
function buildQuery() {
const parts = []
if (ghKeyword.value.trim()) parts.push(ghKeyword.value.trim())
if (ghMinStars.value) parts.push(`stars:>${ghMinStars.value}`)
if (ghLang.value) parts.push(`language:${ghLang.value}`)
// 如果什么都没填,默认搜索热门
if (parts.length === 0) parts.push('stars:>1000')
return parts.join(' ')
}
async function buildAndSearch() {
const q = buildQuery()
ghSearching.value = true
ghError.value = ''
ghPage.value = 1
ghSearched.value = false
try {
const res = await projectsApi.githubSearch({ q, sort: ghSort.value, page: 1, per_page: 12 })
ghResults.value = res.data.items
ghTotal.value = res.data.total
ghSearched.value = true
} catch (e) {
ghError.value = e.response?.data?.detail || 'GitHub 搜索失败'
} finally { ghSearching.value = false }
}
async function loadMoreGithub() {
ghSearching.value = true
ghPage.value++
try {
const q = buildQuery()
const res = await projectsApi.githubSearch({ q, sort: ghSort.value, page: ghPage.value, per_page: 12 })
ghResults.value.push(...res.data.items)
} catch (e) { ghPage.value-- }
finally { ghSearching.value = false }
}
function toggleSelect(repo) {
const s = new Set(ghSelected.value)
const m = new Map(ghSelectedData.value)
if (s.has(repo.url)) {
s.delete(repo.url)
m.delete(repo.url)
} else {
s.add(repo.url)
m.set(repo.url, repo)
}
ghSelected.value = s
ghSelectedData.value = m
}
async function importSelected() {
ghImporting.value = true
try {
const items = [...ghSelectedData.value.values()].map(r => ({
name: r.name,
description: r.description,
url: r.url,
homepage: r.homepage,
icon: r.icon,
language: r.language,
category: ghImportCat.value || guessCategory(r),
stars: r.stars,
forks: r.forks,
}))
const res = await projectsApi.githubImport({ items })
const msg = `成功导入 ${res.data.imported} 个项目` + (res.data.skipped > 0 ? `,跳过 ${res.data.skipped} 个重复项目` : '')
alert(msg)
ghSelected.value = new Set()
ghSelectedData.value = new Map()
showGithubModal.value = false
await loadProjects()
} catch (e) {
alert(e.response?.data?.detail || '导入失败')
} finally { ghImporting.value = false }
}
function formatK(n) {
if (!n) return '0'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
function guessCategory(repo) {
const desc = (repo.description || '').toLowerCase()
const topics = (repo.topics || []).join(' ').toLowerCase()
const lang = (repo.language || '').toLowerCase()
const all = `${desc} ${topics} ${lang}`
if (/\b(machine.?learning|deep.?learning|artificial.?intelligence|neural|llm|gpt|transformer|nlp)\b/.test(all)) return 'AI 相关'
if (/\b(react|vue|angular|svelte|next|nuxt|frontend|front.?end|css|tailwind|ui.?component)\b/.test(all)) return '前端框架'
if (/\b(express|django|flask|spring|fastapi|gin|fiber|nest|backend|server|api.?framework)\b/.test(all)) return '后端框架'
if (/\b(database|sql|redis|mongo|postgres|mysql|sqlite|orm)\b/.test(all)) return '数据库'
if (/\b(docker|kubernetes|k8s|ci.?cd|devops|deploy|terraform|ansible|jenkins)\b/.test(all)) return 'DevOps'
if (/\b(ios|android|flutter|react.?native|mobile|swift|kotlin)\b/.test(all)) return '移动端'
if (/\b(cli|tool|utility|library|sdk|package|plugin)\b/.test(all)) return '工具库'
return '其他'
}
</script>

View File

@@ -0,0 +1,199 @@
<template>
<div class="h-full overflow-y-auto p-6">
<div class="max-w-2xl">
<!-- 标题 -->
<div class="mb-6">
<h1 class="text-lg font-bold text-white">对象存储管理</h1>
<p class="text-xs text-gray-500 mt-1">配置腾讯云 COS 对象存储用于文章图片等文件上传未配置时自动使用本地存储</p>
</div>
<!-- 状态卡片 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center"
:class="isConfigured ? 'bg-green-600/20' : 'bg-gray-800'">
<svg class="w-5 h-5" :class="isConfigured ? 'text-green-400' : 'text-gray-500'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"/>
</svg>
</div>
<div>
<div class="text-sm font-medium text-white">腾讯云 COS</div>
<div class="text-xs" :class="isConfigured ? 'text-green-400' : 'text-gray-500'">
{{ isConfigured ? '已配置' : '未配置 · 使用本地存储' }}
</div>
</div>
</div>
<button
v-if="isConfigured"
@click="testConnection"
:disabled="testing"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="testing ? 'border-gray-700 text-gray-500 cursor-wait' : 'border-gray-700 text-gray-300 hover:border-indigo-500 hover:text-indigo-400'"
>
{{ testing ? '测试中...' : '测试连接' }}
</button>
</div>
<!-- 测试结果 -->
<div v-if="testResult" class="mt-3 px-3 py-2 rounded-lg text-xs"
:class="testResult.success ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'">
{{ testResult.message }}
</div>
</div>
<!-- 配置表单 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div class="space-y-4">
<div v-for="field in formFields" :key="field.key">
<label class="block text-xs font-medium text-gray-400 mb-1.5">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="form[field.key]"
:type="field.secret ? (showSecret ? 'text' : 'password') : 'text'"
:placeholder="field.placeholder"
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors"
/>
<p v-if="field.hint" class="text-[11px] text-gray-600 mt-1">{{ field.hint }}</p>
<button v-if="field.secret" @click="showSecret = !showSecret" class="text-[11px] text-gray-500 hover:text-gray-300 mt-1">
{{ showSecret ? '隐藏' : '显示' }}密钥
</button>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-800">
<p class="text-[11px] text-gray-600">修改后需点击保存才能生效</p>
<div class="flex gap-2">
<button @click="resetForm" class="px-4 py-2 text-xs text-gray-400 hover:text-gray-200 transition-colors">
重置
</button>
<button
@click="saveConfig"
:disabled="saving"
class="px-4 py-2 text-xs rounded-lg transition-colors"
:class="saving ? 'bg-indigo-800 text-indigo-300 cursor-wait' : 'bg-indigo-600 text-white hover:bg-indigo-500'"
>
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</div>
<!-- 说明 -->
<div class="mt-6 bg-gray-900/50 border border-gray-800/50 rounded-xl p-4">
<h3 class="text-xs font-medium text-gray-400 mb-2">配置说明</h3>
<ul class="text-[11px] text-gray-500 space-y-1.5">
<li>1. 前往<a href="https://console.cloud.tencent.com/cos" target="_blank" class="text-indigo-400 hover:underline mx-1">腾讯云 COS 控制台</a>创建存储桶</li>
<li>2. 存储桶建议选择<span class="text-gray-400">公有读私有写</span>权限以便图片可直接访问</li>
<li>3. Bucket 名称需包含 APPID 后缀 <span class="text-gray-400">bianchengshequ-1250000000</span></li>
<li>4. Region 填写存储桶所在地域 <span class="text-gray-400">ap-beijing</span>与服务器同地域可走内网</li>
<li>5. 自定义域名为可选项如果你配置了 CDN 加速域名可以填写</li>
<li>6. 未配置 COS 图片将存储在服务器本地 uploads 目录</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { adminApi } from '../../api/modules'
const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const showSecret = ref(false)
const testResult = ref(null)
const form = reactive({
cos_secret_id: '',
cos_secret_key: '',
cos_bucket: '',
cos_region: '',
cos_custom_domain: '',
})
const formFields = [
{ key: 'cos_secret_id', label: 'SecretId', placeholder: '请输入 SecretId', required: true },
{ key: 'cos_secret_key', label: 'SecretKey', placeholder: '请输入 SecretKey', required: true, secret: true, hint: '保存后界面仅显示脱敏值,实际值安全存储在数据库中' },
{ key: 'cos_bucket', label: 'Bucket', placeholder: '如 bianchengshequ-1250000000', required: true, hint: 'Bucket 名称需包含 APPID 后缀' },
{ key: 'cos_region', label: 'Region', placeholder: '如 ap-beijing', required: true, hint: '存储桶所在地域,如 ap-beijing、ap-shanghai、ap-guangzhou' },
{ key: 'cos_custom_domain', label: '自定义域名(可选)', placeholder: '如 cdn.yourdomain.com', hint: 'CDN 加速域名,不填则使用 COS 默认域名' },
]
const isConfigured = computed(() => {
return !!(form.cos_secret_id && form.cos_bucket && form.cos_region)
})
let originalConfig = {}
onMounted(async () => {
try {
const { data } = await adminApi.getStorageConfig()
const config = data.config
form.cos_secret_id = config.cos_secret_id || ''
form.cos_secret_key = '' // secret 不回传,用户重新输入
form.cos_bucket = config.cos_bucket || ''
form.cos_region = config.cos_region || ''
form.cos_custom_domain = config.cos_custom_domain || ''
originalConfig = { ...form }
// 如果有脱敏secret显示提示
if (config.cos_secret_key_masked) {
form.cos_secret_key = config.cos_secret_key_masked
}
} catch (e) {
console.error('加载配置失败', e)
} finally {
loading.value = false
}
})
function resetForm() {
Object.assign(form, originalConfig)
testResult.value = null
}
async function saveConfig() {
saving.value = true
testResult.value = null
try {
// 如果secret是脱敏值包含*不提交secret字段
const payload = { ...form }
if (payload.cos_secret_key && payload.cos_secret_key.includes('*')) {
delete payload.cos_secret_key
}
await adminApi.updateStorageConfig(payload)
// 重新加载
const { data } = await adminApi.getStorageConfig()
const config = data.config
form.cos_secret_id = config.cos_secret_id || ''
form.cos_bucket = config.cos_bucket || ''
form.cos_region = config.cos_region || ''
form.cos_custom_domain = config.cos_custom_domain || ''
if (config.cos_secret_key_masked) {
form.cos_secret_key = config.cos_secret_key_masked
}
originalConfig = { ...form }
testResult.value = { success: true, message: '配置保存成功' }
} catch (e) {
testResult.value = { success: false, message: e.response?.data?.detail || '保存失败' }
} finally {
saving.value = false
}
}
async function testConnection() {
testing.value = true
testResult.value = null
try {
const { data } = await adminApi.testStorageConnection()
testResult.value = { success: true, message: data.message || '连接成功' }
} catch (e) {
testResult.value = { success: false, message: e.response?.data?.detail || '连接失败' }
} finally {
testing.value = false
}
}
</script>

View File

@@ -0,0 +1,216 @@
<template>
<div class="h-full overflow-y-auto">
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-white">用户管理</h1>
<span class="text-sm text-gray-500"> {{ total }} 个用户</span>
</div>
<!-- 搜索和筛选 -->
<div class="mb-4 flex items-center gap-3">
<input
v-model="search"
@input="debouncedFetch"
placeholder="搜索用户名..."
class="w-72 px-4 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500"
/>
<div class="flex bg-gray-800 rounded-lg p-0.5">
<button
@click="filterStatus = ''; page = 1; fetchUsers()"
class="px-3 py-1.5 text-xs rounded-md transition-colors"
:class="filterStatus === '' ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-gray-200'"
>全部</button>
<button
@click="filterStatus = 'pending'; page = 1; fetchUsers()"
class="px-3 py-1.5 text-xs rounded-md transition-colors"
:class="filterStatus === 'pending' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-gray-200'"
>待审核 <span v-if="pendingCount" class="ml-1 bg-amber-500/30 px-1.5 rounded">{{ pendingCount }}</span></button>
<button
@click="filterStatus = 'approved'; page = 1; fetchUsers()"
class="px-3 py-1.5 text-xs rounded-md transition-colors"
:class="filterStatus === 'approved' ? 'bg-green-600 text-white' : 'text-gray-400 hover:text-gray-200'"
>已通过</button>
</div>
</div>
<!-- 表格 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table class="w-full">
<thead class="bg-gray-800/50">
<tr>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">用户</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">邮箱</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">文章数</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">评论数</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">注册时间</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500">状态</th>
<th class="text-right px-4 py-3 text-xs font-medium text-gray-500">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id" class="border-t border-gray-800/50 hover:bg-gray-800/30">
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-full bg-gray-800 flex items-center justify-center text-[10px] font-bold text-indigo-400 shrink-0">
{{ u.username.charAt(0).toUpperCase() }}
</div>
<span class="text-sm text-gray-200">{{ u.username }}</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ u.email }}</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ u.post_count }}</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ u.comment_count }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ formatDate(u.created_at) }}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-1.5">
<span v-if="!u.is_approved" class="text-[10px] bg-amber-500/20 text-amber-400 px-1.5 py-0.5 rounded">待审核</span>
<span v-if="u.is_admin" class="text-[10px] bg-indigo-500/20 text-indigo-400 px-1.5 py-0.5 rounded">管理员</span>
<span v-if="u.is_banned" class="text-[10px] bg-red-500/20 text-red-400 px-1.5 py-0.5 rounded">已封禁</span>
<span v-if="u.is_approved && !u.is_admin && !u.is_banned" class="text-[10px] bg-gray-700/50 text-gray-500 px-1.5 py-0.5 rounded">正常</span>
</div>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<button
v-if="!u.is_approved"
@click="approveUser(u)"
class="text-xs text-green-400 hover:text-green-300 transition-colors"
>通过审核</button>
<button
@click="toggleAdmin(u)"
class="text-xs transition-colors"
:class="u.is_admin ? 'text-orange-400 hover:text-orange-300' : 'text-indigo-400 hover:text-indigo-300'"
>
{{ u.is_admin ? '取消管理员' : '设为管理员' }}
</button>
<button
v-if="!u.is_admin && u.is_approved"
@click="toggleBan(u)"
class="text-xs transition-colors"
:class="u.is_banned ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'"
>
{{ u.is_banned ? '解封' : '封禁' }}
</button>
<button
v-if="u.is_approved && !u.is_admin"
@click="rejectUser(u)"
class="text-xs text-gray-500 hover:text-red-400 transition-colors"
>撤回审核</button>
</div>
</td>
</tr>
<tr v-if="users.length === 0 && !loading">
<td colspan="7" class="px-4 py-12 text-center text-gray-600 text-sm">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex justify-center gap-1 mt-4">
<button
v-for="p in totalPages"
:key="p"
@click="page = p; fetchUsers()"
class="w-8 h-8 rounded text-xs transition-colors"
:class="page === p ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'"
>
{{ p }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { adminApi } from '../../api/modules'
const users = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const search = ref('')
const loading = ref(false)
const filterStatus = ref('')
const pendingCount = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
let debounceTimer = null
function debouncedFetch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => { page.value = 1; fetchUsers() }, 300)
}
async function fetchUsers() {
loading.value = true
try {
const res = await adminApi.getUsers({ search: search.value, page: page.value, page_size: pageSize })
let items = res.data.items
// 前端筛选
if (filterStatus.value === 'pending') {
items = items.filter(u => !u.is_approved)
} else if (filterStatus.value === 'approved') {
items = items.filter(u => u.is_approved)
}
users.value = items
total.value = res.data.total
// 更新待审核计数
pendingCount.value = res.data.items.filter(u => !u.is_approved).length
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function toggleAdmin(u) {
if (!confirm(`确定${u.is_admin ? '取消' : '设为'}管理员:${u.username}`)) return
try {
const res = await adminApi.toggleAdmin(u.id)
u.is_admin = res.data.is_admin
} catch (e) {
alert(e.response?.data?.detail || '操作失败')
}
}
async function toggleBan(u) {
if (!confirm(`确定${u.is_banned ? '解封' : '封禁'}用户:${u.username}`)) return
try {
const res = await adminApi.toggleBan(u.id)
u.is_banned = res.data.is_banned
} catch (e) {
alert(e.response?.data?.detail || '操作失败')
}
}
async function approveUser(u) {
if (!confirm(`确定审核通过用户:${u.username}`)) return
try {
await adminApi.approveUser(u.id)
u.is_approved = true
pendingCount.value = Math.max(0, pendingCount.value - 1)
} catch (e) {
alert(e.response?.data?.detail || '操作失败')
}
}
async function rejectUser(u) {
if (!confirm(`确定撤回用户审核:${u.username}?该用户将无法登录`)) return
try {
await adminApi.rejectUser(u.id)
u.is_approved = false
pendingCount.value += 1
} catch (e) {
alert(e.response?.data?.detail || '操作失败')
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN')
}
onMounted(() => fetchUsers())
</script>

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

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/uploads': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})