feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
277
frontend/src/api/companies.ts
Normal file
277
frontend/src/api/companies.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import api from '@/api'
|
||||
|
||||
export interface Company {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
industry: string | null
|
||||
department_count: number
|
||||
ceo_agent_id: string | null
|
||||
status: string
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
department_type: string | null
|
||||
members: DepartmentMember[] | null
|
||||
}
|
||||
|
||||
export interface DepartmentMember {
|
||||
id: string
|
||||
agent_id: string
|
||||
role: string
|
||||
is_lead: boolean
|
||||
agent?: any
|
||||
}
|
||||
|
||||
export interface CompanyProject {
|
||||
id: string
|
||||
company_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
status: string
|
||||
ceo_plan: any
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface CompanyDetail extends Company {
|
||||
departments: Department[]
|
||||
}
|
||||
|
||||
export async function listCompanies(): Promise<Company[]> {
|
||||
const resp = await api.get('/api/v1/companies')
|
||||
return resp.data.companies || []
|
||||
}
|
||||
|
||||
export async function getCompany(id: string): Promise<CompanyDetail> {
|
||||
const resp = await api.get(`/api/v1/companies/${id}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function createCompanyFromPreset(presetType: string, companyName?: string): Promise<any> {
|
||||
const params = companyName ? { company_name: companyName } : {}
|
||||
const resp = await api.post(`/api/v1/companies/template/${presetType}`, null, { params })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function createCompany(data: { name: string; description?: string; industry?: string }): Promise<any> {
|
||||
const resp = await api.post('/api/v1/companies', data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateCompany(id: string, data: { name?: string; description?: string; industry?: string }): Promise<any> {
|
||||
const resp = await api.put(`/api/v1/companies/${id}`, data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteCompany(id: string): Promise<void> {
|
||||
await api.delete(`/api/v1/companies/${id}`)
|
||||
}
|
||||
|
||||
export async function executeCompany(companyId: string, projectDescription: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/execute`, { project_description: projectDescription })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function addDepartment(companyId: string, teamId: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/departments`, { team_id: teamId })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function removeDepartment(companyId: string, departmentId: string): Promise<void> {
|
||||
await api.delete(`/api/v1/companies/${companyId}/departments/${departmentId}`)
|
||||
}
|
||||
|
||||
export async function listProjects(companyId: string): Promise<CompanyProject[]> {
|
||||
const resp = await api.get(`/api/v1/companies/${companyId}/projects`)
|
||||
return resp.data.projects || []
|
||||
}
|
||||
|
||||
export async function exportCompany(companyId: string): Promise<void> {
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
const resp = await fetch(`/api/v1/companies/${companyId}/export`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!resp.ok) throw new Error('Export failed')
|
||||
const blob = await resp.blob()
|
||||
const disposition = resp.headers.get('Content-Disposition') || ''
|
||||
const match = disposition.match(/filename="?(.+)"?/)
|
||||
const filename = match ? match[1] : 'company_export.md'
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function getCompanyStats(companyId: string): Promise<{
|
||||
total_projects: number
|
||||
completed_projects: number
|
||||
success_rate: number
|
||||
last_active_at: string | null
|
||||
}> {
|
||||
const resp = await api.get(`/api/v1/companies/${companyId}/stats`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
// ─── Schedules ───
|
||||
export interface CompanySchedule {
|
||||
id: string
|
||||
company_id: string
|
||||
name: string
|
||||
project_goal: string
|
||||
cron_expression: string
|
||||
interval_minutes: number | null
|
||||
enabled: boolean
|
||||
last_run_at: string | null
|
||||
next_run_at: string | null
|
||||
}
|
||||
|
||||
export async function listSchedules(companyId: string): Promise<CompanySchedule[]> {
|
||||
const resp = await api.get(`/api/v1/companies/${companyId}/schedules`)
|
||||
return resp.data.schedules || []
|
||||
}
|
||||
|
||||
export async function createSchedule(companyId: string, data: {
|
||||
name: string; project_goal: string; cron_expression: string; interval_minutes?: number
|
||||
}): Promise<any> {
|
||||
const payload: any = { ...data }
|
||||
if (payload.interval_minutes !== undefined) payload.interval_minutes = String(payload.interval_minutes)
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/schedules`, payload)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function updateSchedule(scheduleId: string, data: {
|
||||
name?: string; project_goal?: string; cron_expression?: string; interval_minutes?: number; enabled?: boolean
|
||||
}): Promise<any> {
|
||||
const payload: any = { ...data }
|
||||
if (payload.interval_minutes !== undefined) payload.interval_minutes = String(payload.interval_minutes)
|
||||
const resp = await api.put(`/api/v1/companies/schedules/${scheduleId}`, payload)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteSchedule(scheduleId: string): Promise<void> {
|
||||
await api.delete(`/api/v1/companies/schedules/${scheduleId}`)
|
||||
}
|
||||
|
||||
export async function triggerSchedule(scheduleId: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/schedules/${scheduleId}/trigger`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
// ─── Knowledge ───
|
||||
export interface CompanyKnowledge {
|
||||
id: string
|
||||
company_id: string
|
||||
project_id: string | null
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
tags: string[]
|
||||
source_dept: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function listKnowledge(companyId: string, params?: { search?: string; category?: string; limit?: number }): Promise<CompanyKnowledge[]> {
|
||||
const resp = await api.get(`/api/v1/companies/${companyId}/knowledge`, { params })
|
||||
return resp.data.knowledge || []
|
||||
}
|
||||
|
||||
export async function generateKnowledge(companyId: string, projectId: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/knowledge/generate`, { project_id: projectId })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function deleteKnowledge(companyId: string, knowledgeId: string): Promise<void> {
|
||||
await api.delete(`/api/v1/companies/${companyId}/knowledge/${knowledgeId}`)
|
||||
}
|
||||
|
||||
// ─── Insights ───
|
||||
export interface CompanyInsights {
|
||||
total_projects: number
|
||||
completed_projects: number
|
||||
failed_projects: number
|
||||
success_rate: number
|
||||
dept_stats: { name: string; execution_count: number; success_count: number; avg_score: number; success_rate: number }[]
|
||||
collaboration_pairs: { pair: string; count: number }[]
|
||||
avg_rounds: number
|
||||
avg_cost: number
|
||||
failure_reasons: { project: string; department: string; score: number; feedback: string }[]
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
export async function getCompanyInsights(companyId: string): Promise<CompanyInsights> {
|
||||
const resp = await api.get(`/api/v1/companies/${companyId}/insights`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
// ─── Template Marketplace ───
|
||||
export interface ProjectTemplate {
|
||||
id: string
|
||||
company_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
ceo_plan: any
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
export async function listTemplates(params?: { search?: string }): Promise<ProjectTemplate[]> {
|
||||
const resp = await api.get('/api/v1/companies/templates/list', { params })
|
||||
return resp.data.templates || []
|
||||
}
|
||||
|
||||
export async function publishProject(companyId: string, projectId: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/projects/${projectId}/publish`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function unpublishProject(companyId: string, projectId: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/projects/${projectId}/unpublish`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
// ─── Project Supervisor / 项目监管 ───
|
||||
export interface ZombieProject {
|
||||
project_id: string
|
||||
name: string
|
||||
company_id: string
|
||||
status: string
|
||||
idle_minutes: number
|
||||
last_updated: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface InProgressProject {
|
||||
project_id: string
|
||||
name: string
|
||||
company_id: string
|
||||
status: string
|
||||
idle_minutes: number
|
||||
is_at_risk: boolean
|
||||
will_become_zombie_in_minutes: number
|
||||
last_updated: string | null
|
||||
}
|
||||
|
||||
export async function getZombieProjects(): Promise<{ zombie_count: number; projects: ZombieProject[]; scanned_at: string | null }> {
|
||||
const resp = await api.get('/api/v1/companies/supervisor/zombies')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function getInProgressProjects(): Promise<{ projects: InProgressProject[] }> {
|
||||
const resp = await api.get('/api/v1/companies/supervisor/in-progress')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function triggerSupervisorScan(): Promise<any> {
|
||||
const resp = await api.post('/api/v1/companies/supervisor/scan')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function recoverProject(companyId: string, projectId: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/projects/${projectId}/recover`)
|
||||
return resp.data
|
||||
}
|
||||
@@ -62,11 +62,14 @@ api.interceptors.request.use(
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
// 单例 refresh promise,避免并发 401 触发多次刷新
|
||||
let refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const skip = Boolean(
|
||||
(error.config as { skipErrorHandler?: boolean } | undefined)?.skipErrorHandler
|
||||
)
|
||||
@@ -77,14 +80,40 @@ api.interceptors.response.use(
|
||||
if (skip) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 处理401未授权
|
||||
|
||||
// 处理401未授权:先尝试用 refresh_token 静默刷新并重试原请求
|
||||
if (status === 401) {
|
||||
// 延迟导入避免循环依赖:通过 Pinia store 统一清理认证状态
|
||||
const originalConfig = error.config as
|
||||
| (import('axios').AxiosRequestConfig & { _retry?: boolean })
|
||||
| undefined
|
||||
const hasRefresh = Boolean(localStorage.getItem('refresh_token'))
|
||||
|
||||
if (originalConfig && !originalConfig._retry && hasRefresh) {
|
||||
originalConfig._retry = true
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = import('@/stores/user')
|
||||
.then(({ useUserStore }) => useUserStore().refreshAccessToken())
|
||||
.finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
const newToken = await refreshPromise
|
||||
if (newToken) {
|
||||
// 重试原请求(请求拦截器会自动注入最新 token)
|
||||
return api(originalConfig)
|
||||
}
|
||||
} catch {
|
||||
// 刷新失败,走下面的登出流程
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新不可用或失败 → 清理认证状态并跳转登录
|
||||
import('@/stores/user').then(({ useUserStore }) => {
|
||||
useUserStore().logout()
|
||||
}).catch(() => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
})
|
||||
router.push('/login')
|
||||
ElMessage.error(data?.message || '登录已过期,请重新登录')
|
||||
|
||||
@@ -133,7 +133,7 @@ import {
|
||||
Grid, Document, User, ChatLineSquare, Share, UserFilled,
|
||||
Clock, Monitor, List, Connection, Setting, Star, Shop,
|
||||
Tools, Bell, Tickets, OfficeBuilding, Lock, ArrowDown, DataAnalysis,
|
||||
Sunny, Moon, Iphone, Fold, Expand, SwitchButton
|
||||
Sunny, Moon, Iphone, Fold, Expand, SwitchButton, MagicStick
|
||||
} from '@element-plus/icons-vue'
|
||||
import GlobalSearch from '@/components/GlobalSearch.vue'
|
||||
import BreadcrumbNav from '@/components/BreadcrumbNav.vue'
|
||||
@@ -184,6 +184,8 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
children: [
|
||||
{ index: 'agent-chat', label: 'Agent对话', description: '与智能体对话交互', path: '/agent-chat', pathPattern: '/agent-chat', icon: ChatLineSquare },
|
||||
{ index: 'teams', label: '虚拟团队', description: '团队管理与数字员工', path: '/teams', pathPattern: '/goals', icon: UserFilled },
|
||||
{ index: 'company-presets', label: 'AI数字员工', description: '一键创建全套公司AI员工', path: '/company-presets', icon: MagicStick },
|
||||
{ index: 'companies', label: '虚拟公司', description: '多层组织架构与多部门协作', path: '/companies', icon: OfficeBuilding },
|
||||
{ index: 'agent-schedules', label: '定时任务', description: 'Agent定时执行', path: '/agent-schedules', icon: Clock },
|
||||
{ index: 'agent-test', label: '快速测试', description: '快速测试Agent', path: '/agent-test', icon: Monitor },
|
||||
],
|
||||
@@ -194,7 +196,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
icon: Shop,
|
||||
children: [
|
||||
{ index: 'template-market', label: '模板市场', description: '浏览安装模板', path: '/template-market', icon: Star },
|
||||
{ index: 'agent-market', label: 'Agent技能商店', description: '浏览安装技能', path: '/agent-market', icon: Shop },
|
||||
{ index: 'agent-market', label: 'Agent 市场', description: '浏览安装 Agent', path: '/agent-market', icon: Shop },
|
||||
{ index: 'tools', label: '工具市场', description: '内置与自定义工具', path: '/tools', icon: Tools },
|
||||
{ index: 'node-templates', label: '节点模板', description: '工作流节点模板', path: '/node-templates', icon: Document },
|
||||
],
|
||||
@@ -221,6 +223,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
{ index: 'permissions', label: '权限管理', description: '用户权限管理', path: '/permissions', icon: Lock, adminOnly: true },
|
||||
{ index: 'app-versions', label: 'App版本管理', description: '发布App新版本', path: '/app-versions', icon: Iphone, adminOnly: true },
|
||||
{ index: 'users', label: '用户管理', description: '用户列表与权限管理', path: '/users', icon: User, adminOnly: true },
|
||||
{ index: 'api-key-management', label: 'API密钥管理', description: '管理所有API密钥', path: '/admin/api-keys', icon: Tickets, adminOnly: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -15,7 +15,6 @@ const SpeechRecognition =
|
||||
export function useSpeechRecognition() {
|
||||
let recognition: any = null;
|
||||
|
||||
// 初始化
|
||||
if (SpeechRecognition) {
|
||||
supported.value = true;
|
||||
}
|
||||
@@ -26,8 +25,14 @@ export function useSpeechRecognition() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any previous recognition instance
|
||||
if (recognition) {
|
||||
try { recognition.stop(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
error.value = '';
|
||||
transcript.value = '';
|
||||
isListening.value = true;
|
||||
|
||||
recognition = new SpeechRecognition();
|
||||
recognition.lang = lang;
|
||||
@@ -35,33 +40,55 @@ export function useSpeechRecognition() {
|
||||
recognition.continuous = false;
|
||||
recognition.maxAlternatives = 1;
|
||||
|
||||
let finalTranscript = '';
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
let final = '';
|
||||
let interim = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
if (result.isFinal) final += result[0].transcript;
|
||||
else transcript.value += result[0].transcript;
|
||||
if (result.isFinal) {
|
||||
finalTranscript += result[0].transcript;
|
||||
} else {
|
||||
interim += result[0].transcript;
|
||||
}
|
||||
}
|
||||
if (final) transcript.value = final;
|
||||
transcript.value = finalTranscript + interim;
|
||||
};
|
||||
|
||||
recognition.onerror = (e: any) => {
|
||||
error.value = e.error === 'no-speech' ? '未检测到语音' : `识别错误: ${e.error}`;
|
||||
console.error('SpeechRecognition error:', e.error, e.message);
|
||||
if (e.error === 'not-allowed') {
|
||||
error.value = '麦克风权限未授权,请点击地址栏锁图标 → 允许麦克风访问';
|
||||
} else if (e.error === 'no-speech') {
|
||||
error.value = '未检测到语音';
|
||||
} else {
|
||||
error.value = `识别错误: ${e.error}`;
|
||||
}
|
||||
isListening.value = false;
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
isListening.value = false;
|
||||
// If still flagged as listening, recognition ended unexpectedly — auto restart
|
||||
if (isListening.value) {
|
||||
try { recognition.start(); } catch { isListening.value = false; }
|
||||
} else {
|
||||
isListening.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
isListening.value = true;
|
||||
recognition.start();
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (e: any) {
|
||||
console.error('SpeechRecognition start failed:', e);
|
||||
error.value = `无法启动语音识别: ${e.message || e}`;
|
||||
isListening.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
isListening.value = false;
|
||||
if (recognition) {
|
||||
recognition.stop();
|
||||
isListening.value = false;
|
||||
try { recognition.stop(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,18 @@ const router = createRouter({
|
||||
component: () => import('@/views/TeamBuilder.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/company-presets',
|
||||
name: 'company-presets',
|
||||
component: () => import('@/views/CompanyPresets.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/companies',
|
||||
name: 'companies',
|
||||
component: () => import('@/views/CompanyBuilder.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/app-versions',
|
||||
name: 'app-versions',
|
||||
@@ -219,6 +231,18 @@ const router = createRouter({
|
||||
name: 'users',
|
||||
component: () => import('@/views/Users.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/Profile.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/api-keys',
|
||||
name: 'api-key-management',
|
||||
component: () => import('@/views/ApiKeyManagement.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { User, Workspace } from '@/types'
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const token = ref<string | null>(localStorage.getItem('token'))
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
|
||||
const workspaces = ref<Workspace[]>([])
|
||||
const currentWorkspaceId = ref<string | null>(null)
|
||||
|
||||
@@ -38,33 +39,74 @@ export const useUserStore = defineStore('user', () => {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
|
||||
// 设置 refresh token
|
||||
const setRefreshToken = (newToken: string | null) => {
|
||||
refreshToken.value = newToken
|
||||
if (newToken) {
|
||||
localStorage.setItem('refresh_token', newToken)
|
||||
} else {
|
||||
localStorage.removeItem('refresh_token')
|
||||
}
|
||||
}
|
||||
|
||||
// 清除token
|
||||
const clearToken = () => {
|
||||
token.value = null
|
||||
localStorage.removeItem('token')
|
||||
setRefreshToken(null)
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
// 登录
|
||||
const login = async (username: string, password: string) => {
|
||||
// 登录(opts 支持图形验证码与「记住我」)
|
||||
const login = async (
|
||||
username: string,
|
||||
password: string,
|
||||
opts: { captchaId?: string; captcha?: string; rememberMe?: boolean } = {}
|
||||
) => {
|
||||
const params = new URLSearchParams()
|
||||
params.append('username', username)
|
||||
params.append('password', password)
|
||||
if (opts.rememberMe) params.append('remember_me', 'true')
|
||||
if (opts.captchaId) params.append('captcha_id', opts.captchaId)
|
||||
if (opts.captcha) params.append('captcha', opts.captcha)
|
||||
|
||||
const response = await api.post('/api/v1/auth/login', params, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
})
|
||||
},
|
||||
// 登录失败由登录页自行处理,避免全局拦截器把 401 当成「登录过期」触发跳转
|
||||
skipErrorHandler: true,
|
||||
} as any)
|
||||
|
||||
if (response.data.access_token) {
|
||||
setToken(response.data.access_token)
|
||||
if (response.data.refresh_token) {
|
||||
setRefreshToken(response.data.refresh_token)
|
||||
}
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 用 refresh_token 静默换取新的 access_token(供响应拦截器调用)
|
||||
const refreshAccessToken = async (): Promise<string | null> => {
|
||||
if (!refreshToken.value) return null
|
||||
const response = await api.post(
|
||||
'/api/v1/auth/refresh',
|
||||
{ refresh_token: refreshToken.value },
|
||||
{ skipErrorHandler: true } as any
|
||||
)
|
||||
if (response.data?.access_token) {
|
||||
setToken(response.data.access_token)
|
||||
if (response.data.refresh_token) {
|
||||
setRefreshToken(response.data.refresh_token)
|
||||
}
|
||||
return response.data.access_token
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 注册
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
const response = await api.post('/api/v1/auth/register', {
|
||||
@@ -146,6 +188,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
refreshToken,
|
||||
workspaces,
|
||||
currentWorkspaceId,
|
||||
currentWorkspace,
|
||||
@@ -155,6 +198,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
register,
|
||||
logout,
|
||||
fetchUser,
|
||||
refreshAccessToken,
|
||||
switchWorkspace,
|
||||
}
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -284,7 +284,7 @@ const myRating = ref(0)
|
||||
const newComment = ref('')
|
||||
const submittingComment = ref(false)
|
||||
|
||||
const viewTitle = ref('Agent 技能市场')
|
||||
const viewTitle = ref('Agent 市场')
|
||||
|
||||
// 加载 Agent 列表
|
||||
const loadAgents = async (url?: string) => {
|
||||
@@ -318,7 +318,7 @@ const loadAgents = async (url?: string) => {
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
viewMode.value = 'market'
|
||||
viewTitle.value = 'Agent 技能市场'
|
||||
viewTitle.value = 'Agent 市场'
|
||||
loadAgents()
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ const switchView = (mode: string) => {
|
||||
viewMode.value = mode
|
||||
currentPage.value = 1
|
||||
if (mode === 'market') {
|
||||
viewTitle.value = 'Agent 技能市场'
|
||||
viewTitle.value = 'Agent 市场'
|
||||
loadAgents()
|
||||
} else if (mode === 'favorites') {
|
||||
viewTitle.value = '我的收藏'
|
||||
|
||||
155
frontend/src/views/ApiKeyManagement.vue
Normal file
155
frontend/src/views/ApiKeyManagement.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="apikey-management-page">
|
||||
<h3>API 密钥管理</h3>
|
||||
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>全部密钥(管理员视图)</span>
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索用户名或密钥名称"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="filteredKeys" style="width:100%" v-loading="loading">
|
||||
<el-table-column prop="name" label="密钥名称" min-width="140" />
|
||||
<el-table-column label="所属用户" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.owner?.username || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="邮箱" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.owner?.email || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="key_prefix" label="前缀" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.key_prefix }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="!row.permissions" type="success" size="small">全部权限</el-tag>
|
||||
<template v-else>
|
||||
<el-tag v-for="p in row.permissions" :key="p" size="small" style="margin-right:4px">{{ p }}</el-tag>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'active' ? '生效' : '已撤销' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.expires_at ? row.expires_at.split('T')[0] : '永不过期' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后使用" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_used_at ? formatTime(row.last_used_at) : '未使用' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 'active'"
|
||||
type="danger" link size="small"
|
||||
@click="revokeKey(row.id)"
|
||||
>
|
||||
撤销
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
|
||||
interface Owner {
|
||||
id: string; username: string; email: string
|
||||
}
|
||||
|
||||
interface ApiKeyRow {
|
||||
id: string; name: string; key_prefix: string; permissions: string[] | null
|
||||
last_used_at: string | null; expires_at: string | null; status: string
|
||||
created_at: string; owner: Owner | null
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const apiKeys = ref<ApiKeyRow[]>([])
|
||||
const searchText = ref('')
|
||||
|
||||
const filteredKeys = computed(() => {
|
||||
if (!searchText.value) return apiKeys.value
|
||||
const q = searchText.value.toLowerCase()
|
||||
return apiKeys.value.filter(k =>
|
||||
k.name.toLowerCase().includes(q) ||
|
||||
(k.owner?.username || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const baseUrl = import.meta.env.DEV ? '' : `${window.location.protocol}//${window.location.hostname}:8037`
|
||||
const token = () => localStorage.getItem('token') || ''
|
||||
|
||||
async function fetchAllKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/v1/api-keys/admin/all`, {
|
||||
headers: { Authorization: `Bearer ${token()}` }
|
||||
})
|
||||
if (resp.ok) {
|
||||
apiKeys.value = await resp.json()
|
||||
} else {
|
||||
ElMessage.error('获取密钥列表失败')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function revokeKey(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('撤销后该密钥立即失效,确认撤销?', '确认撤销', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/v1/api-keys/admin/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token()}` }
|
||||
})
|
||||
if (resp.ok) {
|
||||
ElMessage.success('已撤销')
|
||||
fetchAllKeys()
|
||||
} else {
|
||||
const err = await resp.json()
|
||||
ElMessage.error(err.detail || '撤销失败')
|
||||
}
|
||||
} catch { ElMessage.error('请求失败') }
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => fetchAllKeys())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.apikey-management-page { max-width: 1100px; margin: 0 auto; padding: 20px; }
|
||||
.apikey-management-page h3 { margin-bottom: 20px; }
|
||||
.section-card { margin-bottom: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
2979
frontend/src/views/CompanyBuilder.vue
Normal file
2979
frontend/src/views/CompanyBuilder.vue
Normal file
File diff suppressed because it is too large
Load Diff
445
frontend/src/views/CompanyPresets.vue
Normal file
445
frontend/src/views/CompanyPresets.vue
Normal file
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="company-presets-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>AI 数字员工工厂</h2>
|
||||
<p class="subtitle">选择行业,一键创建覆盖公司全职能的 AI 组织班底</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 1:选择行业 -->
|
||||
<div class="step-section" v-if="!selectedPreset">
|
||||
<div class="step-title">选择您的行业类型</div>
|
||||
<div class="preset-grid">
|
||||
<el-card
|
||||
v-for="preset in presets"
|
||||
:key="preset.id"
|
||||
class="preset-card"
|
||||
:shadow="'hover'"
|
||||
@click="selectPreset(preset)"
|
||||
>
|
||||
<div class="preset-icon">{{ preset.icon }}</div>
|
||||
<h3>{{ preset.name }}</h3>
|
||||
<p class="preset-industry">{{ preset.industry }}</p>
|
||||
<p class="preset-desc">{{ preset.description }}</p>
|
||||
<el-tag size="small" type="info">{{ preset.role_count }} 个岗位角色</el-tag>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2:选择角色 -->
|
||||
<div class="step-section" v-if="selectedPreset && !created">
|
||||
<div class="step-nav">
|
||||
<el-button text @click="selectedPreset = null">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回行业选择
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="step-title">
|
||||
{{ selectedPreset.icon }} {{ selectedPreset.name }} — 组织班底配置
|
||||
</div>
|
||||
<p class="preset-industry">{{ selectedPreset.industry }}</p>
|
||||
|
||||
<!-- 按分组展示角色 -->
|
||||
<div v-for="group in roleGroups" :key="group.name" class="role-group">
|
||||
<div class="group-header">
|
||||
<span class="group-name">{{ group.name }}</span>
|
||||
<el-button size="small" text type="primary" @click="toggleGroup(group.name)">
|
||||
{{ group.allSelected ? '取消全选' : '全选' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="role-grid">
|
||||
<el-card
|
||||
v-for="role in group.roles"
|
||||
:key="role.role"
|
||||
:class="['role-card', { selected: selectedRoles.has(role.role) }]"
|
||||
:shadow="selectedRoles.has(role.role) ? 'always' : 'hover'"
|
||||
@click="toggleRole(role.role)"
|
||||
>
|
||||
<div class="role-check">
|
||||
<el-checkbox :model-value="selectedRoles.has(role.role)" />
|
||||
</div>
|
||||
<h4>{{ role.name }}</h4>
|
||||
<p class="role-desc">{{ role.description }}</p>
|
||||
<div class="role-meta">
|
||||
<el-tag size="small" effect="plain">{{ role.model }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="info">{{ role.tools.length }} 工具</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning">max {{ role.max_iterations }}轮</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="create-bar">
|
||||
<div class="selected-count">
|
||||
已选 <strong>{{ selectedRoles.size }}</strong> / {{ selectedPreset.role_count }} 个角色
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="creating"
|
||||
:disabled="selectedRoles.size === 0"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
一键创建 {{ selectedRoles.size }} 位 AI 数字员工
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 3:创建完成 -->
|
||||
<div class="step-section" v-if="created">
|
||||
<div class="result-header">
|
||||
<el-result icon="success" title="AI 数字员工创建成功!">
|
||||
<template #sub-title>
|
||||
共创建 <strong>{{ createdResult.created }}</strong> 个新 Agent
|
||||
<span v-if="createdResult.reused > 0">,复用 <strong>{{ createdResult.reused }}</strong> 个已有 Agent</span>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
|
||||
<div v-for="group in createdGroups" :key="group.name" class="created-group">
|
||||
<h4 class="created-group-name">{{ group.name }}</h4>
|
||||
<el-table :data="group.agents" size="small" stripe>
|
||||
<el-table-column prop="name" label="Agent 名称" width="200" />
|
||||
<el-table-column prop="role" label="角色标识" width="160" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.reused ? 'info' : 'success'" size="small">
|
||||
{{ row.reused ? '复用' : '新建' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<el-button type="primary" @click="router.push('/agents')">
|
||||
查看全部 Agent
|
||||
</el-button>
|
||||
<el-button @click="resetAll">
|
||||
创建更多
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, MagicStick } from '@element-plus/icons-vue'
|
||||
import api from '@/api'
|
||||
|
||||
interface RoleInfo {
|
||||
role: string
|
||||
name: string
|
||||
group: string
|
||||
description: string
|
||||
tools: string[]
|
||||
temperature: number
|
||||
model: string
|
||||
max_iterations: number
|
||||
}
|
||||
|
||||
interface Preset {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
industry: string
|
||||
role_count: number
|
||||
roles: RoleInfo[]
|
||||
}
|
||||
|
||||
interface CreatedAgent {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
group: string
|
||||
reused: boolean
|
||||
}
|
||||
|
||||
interface CreatedResult {
|
||||
created: number
|
||||
reused: number
|
||||
total: number
|
||||
agents: CreatedAgent[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const presets = ref<Preset[]>([])
|
||||
const selectedPreset = ref<Preset | null>(null)
|
||||
const selectedRoles = ref<Set<string>>(new Set())
|
||||
const creating = ref(false)
|
||||
const created = ref(false)
|
||||
const createdResult = ref<CreatedResult>({ created: 0, reused: 0, total: 0, agents: [] })
|
||||
const createdAgents = ref<CreatedAgent[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await api.get('/api/v1/company-presets')
|
||||
presets.value = resp.data.presets || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载预设包失败: ' + (e?.message || '未知错误'))
|
||||
}
|
||||
})
|
||||
|
||||
function selectPreset(preset: Preset) {
|
||||
selectedPreset.value = preset
|
||||
// 默认全选
|
||||
selectedRoles.value = new Set(preset.roles.map(r => r.role))
|
||||
}
|
||||
|
||||
const roleGroups = computed(() => {
|
||||
if (!selectedPreset.value) return []
|
||||
const groups: Record<string, { name: string; roles: RoleInfo[]; allSelected: boolean }> = {}
|
||||
for (const role of selectedPreset.value.roles) {
|
||||
if (!groups[role.group]) {
|
||||
groups[role.group] = { name: role.group, roles: [], allSelected: true }
|
||||
}
|
||||
groups[role.group].roles.push(role)
|
||||
}
|
||||
for (const g of Object.values(groups)) {
|
||||
g.allSelected = g.roles.every(r => selectedRoles.value.has(r.role))
|
||||
}
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
function toggleRole(roleKey: string) {
|
||||
const next = new Set(selectedRoles.value)
|
||||
if (next.has(roleKey)) {
|
||||
next.delete(roleKey)
|
||||
} else {
|
||||
next.add(roleKey)
|
||||
}
|
||||
selectedRoles.value = next
|
||||
}
|
||||
|
||||
function toggleGroup(groupName: string) {
|
||||
const group = roleGroups.value.find(g => g.name === groupName)
|
||||
if (!group) return
|
||||
const next = new Set(selectedRoles.value)
|
||||
if (group.allSelected) {
|
||||
for (const r of group.roles) next.delete(r.role)
|
||||
} else {
|
||||
for (const r of group.roles) next.add(r.role)
|
||||
}
|
||||
selectedRoles.value = next
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!selectedPreset.value || selectedRoles.value.size === 0) return
|
||||
creating.value = true
|
||||
try {
|
||||
const resp = await api.post(`/api/v1/company-presets/${selectedPreset.value.id}`, {
|
||||
selected_roles: [...selectedRoles.value],
|
||||
})
|
||||
createdResult.value = resp.data
|
||||
createdAgents.value = resp.data.agents || []
|
||||
created.value = true
|
||||
ElMessage.success(`成功创建 ${resp.data.created} 位 AI 数字员工!`)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || '创建失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createdGroups = computed(() => {
|
||||
if (!selectedPreset.value) return []
|
||||
const groupOrder = [...new Set(selectedPreset.value.roles.map(r => r.group))]
|
||||
const groups: { name: string; agents: CreatedAgent[] }[] = []
|
||||
for (const gn of groupOrder) {
|
||||
const agents = createdAgents.value.filter(a => a.group === gn)
|
||||
if (agents.length > 0) groups.push({ name: gn, agents })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function resetAll() {
|
||||
selectedPreset.value = null
|
||||
selectedRoles.value = new Set()
|
||||
created.value = false
|
||||
createdResult.value = { created: 0, reused: 0, total: 0, agents: [] }
|
||||
createdAgents.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.company-presets-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.page-header h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.step-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.step-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.step-nav {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.preset-industry {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
/* 行业卡片网格 */
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.preset-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.preset-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.preset-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.preset-card h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 角色分组 */
|
||||
.role-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.group-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.group-name::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 3px;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.role-card {
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.role-card:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
.role-card.selected {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.role-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
.role-card h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.role-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.role-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 创建栏 */
|
||||
.create-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 32px;
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
position: sticky;
|
||||
bottom: 16px;
|
||||
}
|
||||
.selected-count {
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* 结果 */
|
||||
.result-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.created-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.created-group-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -76,15 +76,32 @@
|
||||
|
||||
<transition name="el-zoom-in-top">
|
||||
<el-form-item v-if="showCaptcha" prop="captcha">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="验证码"
|
||||
:prefix-icon="KeyIcon"
|
||||
:maxlength="6"
|
||||
clearable
|
||||
autocomplete="off"
|
||||
tabindex="3"
|
||||
/>
|
||||
<div style="display:flex; gap:8px; align-items:center; width:100%;">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="验证码"
|
||||
:prefix-icon="KeyIcon"
|
||||
:maxlength="6"
|
||||
clearable
|
||||
autocomplete="off"
|
||||
tabindex="3"
|
||||
style="flex:1;"
|
||||
/>
|
||||
<img
|
||||
v-if="captchaImg"
|
||||
:src="captchaImg"
|
||||
title="看不清?点击刷新"
|
||||
style="width:110px; height:40px; border-radius:6px; cursor:pointer; border:1px solid var(--el-border-color); flex-shrink:0;"
|
||||
@click="fetchCaptcha"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="width:110px; height:40px; border-radius:6px; cursor:pointer; border:1px dashed var(--el-border-color); display:flex; align-items:center; justify-content:center; font-size:13px; color:var(--el-text-color-secondary); flex-shrink:0;"
|
||||
@click="fetchCaptcha"
|
||||
>
|
||||
{{ captchaLoading ? '加载中' : '点击获取' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</transition>
|
||||
|
||||
@@ -277,12 +294,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import { User as UserIcon, Lock as LockIcon, Key as KeyIcon, Message as MessageIcon } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import api from '@/api'
|
||||
|
||||
// ── types ──────────────────────────────────────────
|
||||
type LoginState = 'idle' | 'validating' | 'loading' | 'success' | 'error' | 'disabled'
|
||||
@@ -303,6 +321,12 @@ const failedCount = ref(0)
|
||||
const errorMessage = ref('')
|
||||
const countdown = ref(60)
|
||||
|
||||
// ── captcha state ──────────────────────────────────
|
||||
const captchaRequired = ref(false) // 后端标记需要验证码
|
||||
const captchaId = ref('')
|
||||
const captchaImg = ref('')
|
||||
const captchaLoading = ref(false)
|
||||
|
||||
// ── forgot password state ──────────────────────────
|
||||
const forgotPwdVisible = ref(false)
|
||||
const forgotLoading = ref(false)
|
||||
@@ -376,10 +400,30 @@ const registerRules: FormRules = {
|
||||
}
|
||||
|
||||
// ── computed ───────────────────────────────────────
|
||||
const showCaptcha = computed(() => failedCount.value >= 3)
|
||||
const showCaptcha = computed(() => captchaRequired.value || failedCount.value >= 3)
|
||||
const isSubmitDisabled = computed(() => loginState.value === 'disabled')
|
||||
const isFormDisabled = computed(() => loginState.value === 'loading' || loginState.value === 'disabled')
|
||||
|
||||
// ── captcha ────────────────────────────────────────
|
||||
async function fetchCaptcha() {
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/auth/captcha', { skipErrorHandler: true } as any)
|
||||
captchaId.value = data.captcha_id
|
||||
captchaImg.value = data.image
|
||||
loginForm.captcha = ''
|
||||
} catch {
|
||||
// 获取失败静默忽略,用户可点击图片重试
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码从隐藏变为显示时,自动拉取一张
|
||||
watch(showCaptcha, (visible) => {
|
||||
if (visible && !captchaImg.value) fetchCaptcha()
|
||||
})
|
||||
|
||||
const submitText = computed(() => {
|
||||
if (loginState.value === 'disabled') return `请 ${countdown.value} 秒后重试`
|
||||
if (loginState.value === 'loading') return activeTab.value === 'login' ? '登录中...' : '注册中...'
|
||||
@@ -432,8 +476,15 @@ async function handleLogin() {
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await userStore.login(loginForm.username.trim(), loginForm.password)
|
||||
await userStore.login(loginForm.username.trim(), loginForm.password, {
|
||||
captchaId: captchaId.value,
|
||||
captcha: loginForm.captcha,
|
||||
rememberMe: loginForm.rememberMe,
|
||||
})
|
||||
failedCount.value = 0
|
||||
captchaRequired.value = false
|
||||
captchaId.value = ''
|
||||
captchaImg.value = ''
|
||||
loginForm.captcha = ''
|
||||
setState('success')
|
||||
ElMessage.success('登录成功')
|
||||
@@ -443,7 +494,18 @@ async function handleLogin() {
|
||||
}, 600)
|
||||
} catch (error: any) {
|
||||
failedCount.value++
|
||||
if (showCaptcha.value) loginForm.captcha = ''
|
||||
|
||||
// 后端 detail 可能是字符串或 { message, captcha_required }
|
||||
const raw = error?.response?.data?.detail
|
||||
const isObj = raw && typeof raw === 'object'
|
||||
const detail = (isObj ? raw.message : raw) || error?.message || '用户名或密码错误'
|
||||
if (isObj && raw.captcha_required) captchaRequired.value = true
|
||||
|
||||
// 需要验证码时刷新图片,清空旧输入
|
||||
if (showCaptcha.value) {
|
||||
loginForm.captcha = ''
|
||||
await fetchCaptcha()
|
||||
}
|
||||
|
||||
// ── 渐进式错误提示 ──
|
||||
if (failedCount.value >= 5) {
|
||||
@@ -453,12 +515,10 @@ async function handleLogin() {
|
||||
startCooldown(15) // 15秒而非60秒
|
||||
} else if (failedCount.value >= 3) {
|
||||
// 第3-4次:显示忘记密码提示
|
||||
const detail = error?.response?.data?.detail || error?.message || '用户名或密码错误'
|
||||
errorMessage.value = `${detail}。忘记密码?点击下方"忘记密码"进行重置。`
|
||||
setState('error')
|
||||
} else {
|
||||
// 第1-2次:通用错误提示
|
||||
const detail = error?.response?.data?.detail || error?.message || '用户名或密码错误'
|
||||
errorMessage.value = detail
|
||||
setState('error')
|
||||
}
|
||||
|
||||
@@ -21,10 +21,31 @@
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="DeepSeek" value="deepseek" />
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
<el-option label="本地模型" value="local" />
|
||||
<el-option label="阿里通义千问" value="qwen" />
|
||||
<el-option label="智谱 GLM" value="zhipu" />
|
||||
<el-option label="百度文心一言" value="baidu" />
|
||||
<el-option label="月之暗面 Kimi" value="moonshot" />
|
||||
<el-option label="字节豆包" value="bytedance" />
|
||||
<el-option label="MiniMax" value="minimax" />
|
||||
<el-option label="硅基流动" value="siliconflow" />
|
||||
<el-option label="讯飞星火" value="xunfei" />
|
||||
<el-option label="腾讯混元" value="hunyuan" />
|
||||
<el-option label="零一万物" value="yi" />
|
||||
<el-option label="百川" value="baichuan" />
|
||||
<el-option label="OpenRouter" value="openrouter" />
|
||||
<el-option label="Cohere" value="cohere" />
|
||||
<el-option label="xAI" value="xai" />
|
||||
<el-option label="Together" value="together" />
|
||||
<el-option label="Fireworks" value="fireworks" />
|
||||
<el-option label="Perplexity" value="perplexity" />
|
||||
<el-option label="DeepInfra" value="deepinfra" />
|
||||
<el-option label="Groq" value="groq" />
|
||||
<el-option label="Google Gemini" value="google" />
|
||||
<el-option label="Mistral AI" value="mistral" />
|
||||
<el-option label="本地/其他" value="local" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch" style="margin-left: 10px">
|
||||
<el-icon><Search /></el-icon>
|
||||
@@ -46,7 +67,7 @@
|
||||
<el-table-column prop="name" label="名称" width="200" />
|
||||
<el-table-column prop="provider" label="提供商" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.provider.toUpperCase() }}</el-tag>
|
||||
<el-tag>{{ providerDisplay(row.provider) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="model_name" label="模型名称" width="200" />
|
||||
@@ -92,70 +113,131 @@
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
width="720px"
|
||||
@close="handleDialogClose"
|
||||
>
|
||||
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入配置名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提供商" prop="provider">
|
||||
<el-select v-model="form.provider" placeholder="选择提供商">
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="DeepSeek" value="deepseek" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
<el-option label="本地模型" value="local" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型名称" prop="model_name">
|
||||
<el-select
|
||||
v-model="form.model_name"
|
||||
placeholder="选择或输入模型名称"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="name in providerModelOptions"
|
||||
:key="name"
|
||||
:label="name"
|
||||
:value="name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="API密钥" prop="api_key">
|
||||
<el-input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入API密钥"
|
||||
/>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-top: 5px;"
|
||||
>
|
||||
<template #title>
|
||||
<div style="font-size: 12px;">
|
||||
API密钥将加密存储,不会在列表中显示
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-form-item>
|
||||
<el-form-item label="API地址" prop="base_url">
|
||||
<el-input
|
||||
v-model="form.base_url"
|
||||
placeholder="可选,例如: https://api.openai.com/v1"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 编辑模式:单一表单 -->
|
||||
<template v-if="currentConfigId">
|
||||
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="可选,区分个人/公司用时填写" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提供商" prop="provider">
|
||||
<el-select v-model="form.provider" placeholder="选择提供商">
|
||||
<el-option label="DeepSeek" value="deepseek" />
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
<el-option label="阿里通义千问" value="qwen" />
|
||||
<el-option label="智谱 GLM" value="zhipu" />
|
||||
<el-option label="百度文心一言" value="baidu" />
|
||||
<el-option label="月之暗面 Kimi" value="moonshot" />
|
||||
<el-option label="字节豆包" value="bytedance" />
|
||||
<el-option label="MiniMax" value="minimax" />
|
||||
<el-option label="硅基流动" value="siliconflow" />
|
||||
<el-option label="讯飞星火" value="xunfei" />
|
||||
<el-option label="腾讯混元" value="hunyuan" />
|
||||
<el-option label="零一万物" value="yi" />
|
||||
<el-option label="百川" value="baichuan" />
|
||||
<el-option label="OpenRouter" value="openrouter" />
|
||||
<el-option label="Cohere" value="cohere" />
|
||||
<el-option label="xAI" value="xai" />
|
||||
<el-option label="Together" value="together" />
|
||||
<el-option label="Fireworks" value="fireworks" />
|
||||
<el-option label="Perplexity" value="perplexity" />
|
||||
<el-option label="DeepInfra" value="deepinfra" />
|
||||
<el-option label="Groq" value="groq" />
|
||||
<el-option label="Google Gemini" value="google" />
|
||||
<el-option label="Mistral AI" value="mistral" />
|
||||
<el-option label="本地/其他" value="local" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型名称" prop="model_name">
|
||||
<el-input v-model="form.model_name" placeholder="模型名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API密钥">
|
||||
<el-input v-model="form.api_key" type="password" show-password placeholder="留空则不修改" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API地址">
|
||||
<el-input v-model="form.base_url" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<!-- 创建模式:全量模型选择面板 -->
|
||||
<template v-else>
|
||||
<div class="create-panel">
|
||||
<div class="create-hint">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
勾选需要的模型,填写对应提供商的 API 密钥。名称自动生成,可选填前缀以区分。
|
||||
</div>
|
||||
|
||||
<el-form label-width="80px" class="name-prefix-row">
|
||||
<el-form-item label="名称前缀">
|
||||
<el-input v-model="customNamePrefix" placeholder="可选,如:公司、我的" style="width: 200px" />
|
||||
<span class="prefix-hint">留空则自动命名为 "DeepSeek deepseek-chat" 等</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 按提供商分组 -->
|
||||
<div v-for="group in modelGroups" :key="group.provider" class="provider-group">
|
||||
<div class="provider-header">
|
||||
<span class="provider-name">{{ group.label }}</span>
|
||||
<span class="provider-count">{{ group.models.filter(m => m.checked).length }}/{{ group.models.length }} 个模型</span>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择网格 -->
|
||||
<div class="model-check-grid">
|
||||
<el-checkbox
|
||||
v-for="model in group.models"
|
||||
:key="model.name"
|
||||
v-model="model.checked"
|
||||
:label="model.name"
|
||||
class="model-check-item"
|
||||
>
|
||||
<span class="model-name">{{ model.name }}</span>
|
||||
<span class="model-desc" v-if="model.desc">{{ model.desc }}</span>
|
||||
<span class="model-price" v-if="model.price">{{ model.price }}</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 该提供商的 API 配置 -->
|
||||
<div v-if="group.models.filter(m => m.checked).length > 0" class="provider-config">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="API 密钥" :label-width="80" size="small">
|
||||
<el-input v-model="group.apiKey" type="password" show-password placeholder="sk-..." size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="地址" :label-width="50" size="small">
|
||||
<el-input v-model="group.baseUrl" placeholder="默认地址" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计 -->
|
||||
<div class="create-summary" v-if="totalSelected > 0">
|
||||
<el-icon><Select /></el-icon>
|
||||
共选择 <b>{{ totalSelected }}</b> 个模型,将为它们创建配置
|
||||
</div>
|
||||
<el-empty v-else description="未选择任何模型" :image-size="48" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 统一 Footer -->
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
确定
|
||||
</el-button>
|
||||
<template v-if="currentConfigId">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleBatchCreate" :loading="submitting" :disabled="totalSelected === 0">
|
||||
创建 {{ totalSelected }} 个配置
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -188,7 +270,9 @@ import {
|
||||
Edit,
|
||||
Delete,
|
||||
Connection,
|
||||
Loading
|
||||
Loading,
|
||||
InfoFilled,
|
||||
Select
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useModelConfigStore } from '@/stores/modelConfig'
|
||||
import type { ModelConfig } from '@/stores/modelConfig'
|
||||
@@ -214,6 +298,299 @@ const form = ref({
|
||||
base_url: ''
|
||||
})
|
||||
const currentConfigId = ref<string | null>(null)
|
||||
const customNamePrefix = ref('')
|
||||
|
||||
// 全量模型列表,按提供商分组
|
||||
interface ModelItem { name: string; desc?: string; price?: string; checked: boolean }
|
||||
interface ProviderGroup { provider: string; label: string; defaultBaseUrl: string; models: ModelItem[]; apiKey: string; baseUrl: string }
|
||||
|
||||
function createModelGroups(): ProviderGroup[] {
|
||||
return [
|
||||
{
|
||||
provider: 'deepseek', label: 'DeepSeek', defaultBaseUrl: 'https://api.deepseek.com/v1',
|
||||
models: [
|
||||
{ name: 'deepseek-chat', desc: '通用对话', price: '¥1/2', checked: false },
|
||||
{ name: 'deepseek-coder', desc: '代码专用', price: '¥1/2', checked: false },
|
||||
{ name: 'deepseek-reasoner', desc: '深度推理', price: '¥1/4', checked: false },
|
||||
{ name: 'deepseek-v4-pro', desc: '最新旗舰', price: '¥1/4', checked: false },
|
||||
{ name: 'deepseek-v4-flash', desc: '极速响应', price: '¥0.5/1', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'openai', label: 'OpenAI', defaultBaseUrl: 'https://api.openai.com/v1',
|
||||
models: [
|
||||
{ name: 'gpt-4o', desc: '最新多模态', price: '$2.5/10', checked: false },
|
||||
{ name: 'gpt-4o-mini', desc: '轻量快速', price: '$0.15/0.6', checked: false },
|
||||
{ name: 'gpt-4-turbo', desc: 'Turbo 版', price: '$10/30', checked: false },
|
||||
{ name: 'o1', desc: '深度推理', price: '$15/60', checked: false },
|
||||
{ name: 'o1-mini', desc: '轻量推理', price: '$1.1/4.4', checked: false },
|
||||
{ name: 'o3-mini', desc: '最新推理', price: '$0.55/2.2', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'anthropic', label: 'Anthropic', defaultBaseUrl: 'https://api.anthropic.com',
|
||||
models: [
|
||||
{ name: 'claude-sonnet-4-6', desc: '最新 Sonnet', price: '$3/15', checked: false },
|
||||
{ name: 'claude-opus-4-6', desc: '最新 Opus', price: '$15/75', checked: false },
|
||||
{ name: 'claude-haiku-4-5-20251001', desc: '最快速度', price: '$0.8/4', checked: false },
|
||||
{ name: 'claude-3-5-sonnet-latest', desc: '稳定版 Sonnet', price: '$3/15', checked: false },
|
||||
{ name: 'claude-3-opus-latest', desc: '稳定版 Opus', price: '$15/75', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'qwen', label: '阿里通义千问', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
models: [
|
||||
{ name: 'qwen-turbo', desc: '通用轻量', price: '¥0.8/0.8', checked: false },
|
||||
{ name: 'qwen-plus', desc: '均衡性能', price: '¥2/2', checked: false },
|
||||
{ name: 'qwen-max', desc: '最强旗舰', price: '¥20/20', checked: false },
|
||||
{ name: 'qwen-long', desc: '超长上下文', price: '¥4/4', checked: false },
|
||||
{ name: 'qwen-coder-plus', desc: '代码专用', price: '¥4/4', checked: false },
|
||||
{ name: 'qwq-plus', desc: '深度推理', price: '¥4/4', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'zhipu', label: '智谱 GLM', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
models: [
|
||||
{ name: 'glm-4-flash', desc: '极速免费', price: '免费', checked: false },
|
||||
{ name: 'glm-4-air', desc: '轻量高性价比', price: '¥0.1/0.1', checked: false },
|
||||
{ name: 'glm-4-plus', desc: '最强旗舰', price: '¥5/5', checked: false },
|
||||
{ name: 'glm-4-long', desc: '超长上下文', price: '¥5/5', checked: false },
|
||||
{ name: 'glm-4.5', desc: '最新升级', price: '¥1/1', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'baidu', label: '百度文心一言', defaultBaseUrl: 'https://qianfan.baidubce.com/v2',
|
||||
models: [
|
||||
{ name: 'ernie-speed-128k', desc: '极速大容量', price: '免费', checked: false },
|
||||
{ name: 'ernie-3.5-128k', desc: '均衡通用', price: '¥0.8/0.8', checked: false },
|
||||
{ name: 'ernie-4.0-turbo-128k', desc: '旗舰推理', price: '¥30/30', checked: false },
|
||||
{ name: 'ernie-4.5', desc: '最新升级', price: '¥4/4', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'moonshot', label: '月之暗面 Kimi', defaultBaseUrl: 'https://api.moonshot.cn/v1',
|
||||
models: [
|
||||
{ name: 'moonshot-v1-8k', desc: '标准长度', price: '¥0.15/0.15', checked: false },
|
||||
{ name: 'moonshot-v1-32k', desc: '中长文本', price: '¥0.3/0.3', checked: false },
|
||||
{ name: 'moonshot-v1-128k', desc: '超长文本', price: '¥0.6/0.6', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'bytedance', label: '字节豆包', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
models: [
|
||||
{ name: 'doubao-lite-128k', desc: '轻量快速', price: '¥0.3/0.3', checked: false },
|
||||
{ name: 'doubao-pro-256k', desc: '主力旗舰', price: '¥0.8/0.8', checked: false },
|
||||
{ name: 'doubao-pro-32k', desc: '高并发版', price: '¥0.8/0.8', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'minimax', label: 'MiniMax', defaultBaseUrl: 'https://api.minimax.chat/v1',
|
||||
models: [
|
||||
{ name: 'abab6.5s-chat', desc: '轻量标准', price: '¥0.3/0.3', checked: false },
|
||||
{ name: 'abab6.5t-chat', desc: '深度推理', price: '¥1/1', checked: false },
|
||||
{ name: 'abab7-chat-preview', desc: '最新旗舰', price: '¥1/1', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'xunfei', label: '讯飞星火', defaultBaseUrl: 'https://spark-api-open.xf-yun.com/v1',
|
||||
models: [
|
||||
{ name: '4.0Ultra', desc: '最强旗舰', price: '¥15/15', checked: false },
|
||||
{ name: '3.5Max', desc: '高性价比', price: '¥3/3', checked: false },
|
||||
{ name: 'lite', desc: '极速轻量', price: '免费', checked: false },
|
||||
{ name: 'pro-128k', desc: '超长上下文', price: '¥3/3', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'hunyuan', label: '腾讯混元', defaultBaseUrl: 'https://api.hunyuan.cloud.tencent.com/v1',
|
||||
models: [
|
||||
{ name: 'hunyuan-turbo', desc: '通用Turbo', price: '¥0.8/0.8', checked: false },
|
||||
{ name: 'hunyuan-pro', desc: '旗舰Pro', price: '¥2/2', checked: false },
|
||||
{ name: 'hunyuan-lite', desc: '免费轻量', price: '免费', checked: false },
|
||||
{ name: 'hunyuan-code', desc: '代码专用', price: '¥2/2', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'yi', label: '零一万物 Yi', defaultBaseUrl: 'https://api.lingyiwanwu.com/v1',
|
||||
models: [
|
||||
{ name: 'yi-large', desc: '旗舰大模型', price: '¥20/20', checked: false },
|
||||
{ name: 'yi-medium', desc: '均衡中杯', price: '¥4/4', checked: false },
|
||||
{ name: 'yi-lightning', desc: '极速闪电', price: '¥0.8/0.8', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'baichuan', label: '百川智能', defaultBaseUrl: 'https://api.baichuan-ai.com/v1',
|
||||
models: [
|
||||
{ name: 'baichuan4', desc: '最新旗舰', price: '¥10/10', checked: false },
|
||||
{ name: 'baichuan3-turbo', desc: '极速Turbo', price: '¥1/1', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'cohere', label: 'Cohere', defaultBaseUrl: 'https://api.cohere.ai/v1',
|
||||
models: [
|
||||
{ name: 'command-r-plus', desc: '最强旗舰', price: '$2.5/10', checked: false },
|
||||
{ name: 'command-r', desc: '高性价比', price: '$0.5/1.5', checked: false },
|
||||
{ name: 'command-a', desc: '最新多模态', price: '$1/4', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'xai', label: 'xAI (Grok)', defaultBaseUrl: 'https://api.x.ai/v1',
|
||||
models: [
|
||||
{ name: 'grok-3', desc: '最新旗舰', price: '$3/15', checked: false },
|
||||
{ name: 'grok-3-mini', desc: '极速轻量', price: '$0.3/1.5', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'together', label: 'Together AI', defaultBaseUrl: 'https://api.together.xyz/v1',
|
||||
models: [
|
||||
{ name: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', desc: 'Llama 3.3 70B', price: '$0.88/0.88', checked: false },
|
||||
{ name: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek V3', price: '$1.25/1.25', checked: false },
|
||||
{ name: 'Qwen/Qwen2.5-72B-Instruct-Turbo', desc: '千问2.5 72B', price: '$0.88/0.88', checked: false },
|
||||
{ name: 'mistralai/Mixtral-8x22B-Instruct-v0.1', desc: 'Mixtral 8x22B', price: '$1.2/1.2', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'fireworks', label: 'Fireworks AI', defaultBaseUrl: 'https://api.fireworks.ai/inference/v1',
|
||||
models: [
|
||||
{ name: 'accounts/fireworks/models/llama-v3p3-70b-instruct', desc: 'Llama 3.3 70B', price: '$0.9/0.9', checked: false },
|
||||
{ name: 'accounts/fireworks/models/deepseek-v3', desc: 'DeepSeek V3', price: '$0.9/0.9', checked: false },
|
||||
{ name: 'accounts/fireworks/models/qwen2p5-72b-instruct', desc: '千问2.5 72B', price: '$0.9/0.9', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'perplexity', label: 'Perplexity', defaultBaseUrl: 'https://api.perplexity.ai',
|
||||
models: [
|
||||
{ name: 'sonar-reasoning-pro', desc: '深度推理', price: '$5/15', checked: false },
|
||||
{ name: 'sonar-pro', desc: '专业搜索', price: '$3/15', checked: false },
|
||||
{ name: 'sonar', desc: '轻量搜索', price: '$1/1', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'deepinfra', label: 'DeepInfra', defaultBaseUrl: 'https://api.deepinfra.com/v1/openai',
|
||||
models: [
|
||||
{ name: 'meta-llama/Llama-3.3-70B-Instruct', desc: 'Llama 3.3 70B', price: '$0.35/0.35', checked: false },
|
||||
{ name: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek V3', price: '$0.89/0.89', checked: false },
|
||||
{ name: 'Qwen/Qwen2.5-72B-Instruct', desc: '千问2.5 72B', price: '$0.35/0.35', checked: false },
|
||||
{ name: 'microsoft/WizardLM-2-8x22B', desc: 'WizardLM 8x22B', price: '$0.5/0.5', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'groq', label: 'Groq (极速推理)', defaultBaseUrl: 'https://api.groq.com/openai/v1',
|
||||
models: [
|
||||
{ name: 'llama-3.3-70b-versatile', desc: 'Llama 3.3 70B', price: '$0.59/0.79', checked: false },
|
||||
{ name: 'llama-3.1-8b-instant', desc: 'Llama 3.1 8B', price: '$0.05/0.08', checked: false },
|
||||
{ name: 'mixtral-8x7b-32768', desc: 'Mixtral 8x7B', price: '$0.24/0.24', checked: false },
|
||||
{ name: 'gemma2-9b-it', desc: 'Gemma 2 9B', price: '$0.2/0.2', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'google', label: 'Google Gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
models: [
|
||||
{ name: 'gemini-2.5-flash', desc: '极速响应', price: '$0.15/0.6', checked: false },
|
||||
{ name: 'gemini-2.5-pro', desc: '最强旗舰', price: '$1.25/5', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'openrouter', label: 'OpenRouter (代理)', defaultBaseUrl: 'https://openrouter.ai/api/v1',
|
||||
models: [
|
||||
// Claude 系列
|
||||
{ name: 'anthropic/claude-sonnet-4-6', desc: 'Claude Sonnet 4.6', price: '$3/15', checked: false },
|
||||
{ name: 'anthropic/claude-opus-4-6', desc: 'Claude Opus 4.6', price: '$15/75', checked: false },
|
||||
{ name: 'anthropic/claude-3.5-sonnet', desc: 'Claude 3.5 Sonnet', price: '$3/15', checked: false },
|
||||
// GPT 系列
|
||||
{ name: 'openai/gpt-4o', desc: 'GPT-4o 多模态', price: '$2.5/10', checked: false },
|
||||
{ name: 'openai/gpt-4o-mini', desc: 'GPT-4o Mini', price: '$0.15/0.6', checked: false },
|
||||
{ name: 'openai/o3-mini', desc: 'o3-mini 推理', price: '$0.55/2.2', checked: false },
|
||||
{ name: 'openai/o1', desc: 'o1 深度推理', price: '$15/60', checked: false },
|
||||
// Gemini 系列
|
||||
{ name: 'google/gemini-2.5-pro', desc: 'Gemini 2.5 Pro', price: '$1.25/5', checked: false },
|
||||
{ name: 'google/gemini-2.5-flash', desc: 'Gemini 2.5 Flash', price: '$0.15/0.6', checked: false },
|
||||
// 开源旗舰
|
||||
{ name: 'meta-llama/llama-4-maverick', desc: 'Llama 4 Maverick', price: '$0.2/0.6', checked: false },
|
||||
{ name: 'deepseek/deepseek-r1', desc: 'DeepSeek R1', price: '$0.55/2.19', checked: false },
|
||||
{ name: 'deepseek/deepseek-chat', desc: 'DeepSeek V3', price: '$0.89/0.89', checked: false },
|
||||
{ name: 'qwen/qwen3-235b-a22b', desc: '千问3 235B', price: '$0.3/0.4', checked: false },
|
||||
{ name: 'mistralai/mistral-large', desc: 'Mistral Large', price: '$2/6', checked: false },
|
||||
{ name: 'cohere/command-r-plus', desc: 'Command R+', price: '$2.5/10', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'siliconflow', label: '硅基流动', defaultBaseUrl: 'https://api.siliconflow.cn/v1',
|
||||
models: [
|
||||
// 国产精选
|
||||
{ name: 'deepseek-ai/DeepSeek-V3.2', desc: 'DeepSeek 最新旗舰', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'deepseek-ai/DeepSeek-R1', desc: 'DeepSeek R1 推理', price: '¥4/16', checked: false },
|
||||
{ name: 'Pro/zai-org/GLM-4.7', desc: 'GLM-4.7 最新', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'Qwen/Qwen3-235B-A22B-Thinking', desc: '千问3 超大推理', price: '¥2.8/2.8', checked: false },
|
||||
{ name: 'Qwen/Qwen2.5-72B-Instruct', desc: '千问2.5 72B', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'Qwen/Qwen2.5-Coder-32B-Instruct', desc: '千问代码 32B', price: '¥1.33/1.33', checked: false },
|
||||
// 国外开源旗舰
|
||||
{ name: 'meta-llama/Meta-Llama-3.1-405B-Instruct', desc: 'Llama 3.1 405B 最强开源', price: '¥4/4', checked: false },
|
||||
{ name: 'meta-llama/Llama-3.3-70B-Instruct', desc: 'Llama 3.3 70B', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'meta-llama/Llama-3.2-90B-Vision-Instruct', desc: 'Llama 3.2 90B 多模态', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'mistralai/Mixtral-8x22B-Instruct-v0.1', desc: 'Mixtral 8x22B MoE', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'mistralai/Mixtral-8x7B-Instruct-v0.1', desc: 'Mixtral 8x7B MoE', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'google/gemma-2-27b-it', desc: 'Gemma 2 27B Google', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'google/gemma-2-9b-it', desc: 'Gemma 2 9B 免费', price: '免费', checked: false },
|
||||
{ name: 'meta-llama/Meta-Llama-3.1-70B-Instruct', desc: 'Llama 3.1 70B', price: '¥1.33/1.33', checked: false },
|
||||
{ name: 'meta-llama/Meta-Llama-3.1-8B-Instruct', desc: 'Llama 3.1 8B 轻量', price: '¥1.33/1.33', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
{
|
||||
provider: 'mistral', label: 'Mistral AI', defaultBaseUrl: 'https://api.mistral.ai/v1',
|
||||
models: [
|
||||
{ name: 'mistral-small', desc: '轻量快速', price: '$0.2/0.6', checked: false },
|
||||
{ name: 'mistral-large', desc: '旗舰模型', price: '$2/6', checked: false },
|
||||
{ name: 'codestral', desc: '代码专用', price: '$0.3/0.9', checked: false },
|
||||
],
|
||||
apiKey: '', baseUrl: ''
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const modelGroups = ref<ProviderGroup[]>(createModelGroups())
|
||||
|
||||
const totalSelected = computed(() => {
|
||||
let n = 0
|
||||
for (const g of modelGroups.value) n += g.models.filter(m => m.checked).length
|
||||
return n
|
||||
})
|
||||
|
||||
const providerDisplayMap: Record<string, string> = {
|
||||
deepseek: 'DeepSeek', openai: 'OpenAI', anthropic: 'Anthropic',
|
||||
qwen: '通义千问', zhipu: '智谱 GLM', baidu: '文心一言',
|
||||
moonshot: 'Kimi', bytedance: '豆包', minimax: 'MiniMax',
|
||||
openrouter: 'OpenRouter', siliconflow: '硅基流动', xunfei: '讯飞星火', hunyuan: '腾讯混元',
|
||||
yi: '零一万物', baichuan: '百川',
|
||||
cohere: 'Cohere', xai: 'xAI', together: 'Together', fireworks: 'Fireworks',
|
||||
perplexity: 'Perplexity', deepinfra: 'DeepInfra', groq: 'Groq',
|
||||
google: 'Gemini', mistral: 'Mistral', local: '本地',
|
||||
}
|
||||
function providerDisplay(p: string) {
|
||||
return providerDisplayMap[p] || p.toUpperCase()
|
||||
}
|
||||
|
||||
// 测试
|
||||
const testDialogVisible = ref(false)
|
||||
@@ -221,33 +598,14 @@ const testing = ref(false)
|
||||
const testResult = ref<any>(null)
|
||||
const currentTestConfig = ref<ModelConfig | null>(null)
|
||||
|
||||
const providerModelOptions = computed(() => {
|
||||
const provider = form.value.provider
|
||||
if (provider === 'openai') {
|
||||
return ['gpt-4o', 'gpt-4-turbo-preview', 'gpt-4', 'gpt-3.5-turbo']
|
||||
}
|
||||
if (provider === 'deepseek') {
|
||||
return ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner', 'deepseek-coder']
|
||||
}
|
||||
if (provider === 'anthropic') {
|
||||
return ['claude-3-5-sonnet-latest', 'claude-3-opus-20240229']
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
// 表单验证规则(仅编辑模式使用)
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入配置名称', trigger: 'blur' }
|
||||
],
|
||||
provider: [
|
||||
{ required: true, message: '请选择提供商', trigger: 'change' }
|
||||
],
|
||||
model_name: [
|
||||
{ required: true, message: '请输入模型名称', trigger: 'blur' }
|
||||
],
|
||||
api_key: [
|
||||
{ required: true, message: '请输入API密钥', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -289,13 +647,8 @@ const loadModelConfigs = async () => {
|
||||
const handleCreate = () => {
|
||||
dialogTitle.value = '创建模型配置'
|
||||
currentConfigId.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
model_name: '',
|
||||
api_key: '',
|
||||
base_url: ''
|
||||
}
|
||||
customNamePrefix.value = ''
|
||||
modelGroups.value = createModelGroups()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -359,33 +712,68 @@ const handleDelete = async (config: ModelConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
// 批量创建
|
||||
const handleBatchCreate = async () => {
|
||||
if (totalSelected.value === 0) {
|
||||
ElMessage.warning('请至少选择一个模型')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
let created = 0
|
||||
try {
|
||||
const prefix = customNamePrefix.value.trim()
|
||||
for (const group of modelGroups.value) {
|
||||
const selected = group.models.filter(m => m.checked)
|
||||
if (selected.length === 0) continue
|
||||
const apiKey = group.apiKey.trim()
|
||||
if (!apiKey) {
|
||||
ElMessage.warning(`请填写 ${group.label} 的 API 密钥`)
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
const baseUrl = group.baseUrl.trim() || group.defaultBaseUrl
|
||||
for (const model of selected) {
|
||||
const name = prefix ? `${prefix} ${group.label} ${model.name}` : `${group.label} ${model.name}`
|
||||
await modelConfigStore.createModelConfig({
|
||||
name,
|
||||
provider: group.provider,
|
||||
model_name: model.name,
|
||||
api_key: apiKey,
|
||||
base_url: baseUrl,
|
||||
})
|
||||
created++
|
||||
}
|
||||
}
|
||||
ElMessage.success(`成功创建 ${created} 个模型配置`)
|
||||
dialogVisible.value = false
|
||||
await loadModelConfigs()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '批量创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单(编辑模式)
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (currentConfigId.value) {
|
||||
// 更新(如果api_key为空,则不更新)
|
||||
const updateData: any = {
|
||||
name: form.value.name,
|
||||
provider: form.value.provider,
|
||||
model_name: form.value.model_name,
|
||||
base_url: form.value.base_url || null
|
||||
}
|
||||
if (form.value.api_key) {
|
||||
updateData.api_key = form.value.api_key
|
||||
}
|
||||
await modelConfigStore.updateModelConfig(currentConfigId.value, updateData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
// 创建
|
||||
await modelConfigStore.createModelConfig(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
const updateData: any = {
|
||||
name: form.value.name,
|
||||
provider: form.value.provider,
|
||||
model_name: form.value.model_name,
|
||||
base_url: form.value.base_url || null
|
||||
}
|
||||
if (form.value.api_key) {
|
||||
updateData.api_key = form.value.api_key
|
||||
}
|
||||
await modelConfigStore.updateModelConfig(currentConfigId.value!, updateData)
|
||||
ElMessage.success('更新成功')
|
||||
dialogVisible.value = false
|
||||
await loadModelConfigs()
|
||||
} catch (error: any) {
|
||||
@@ -439,4 +827,103 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── 创建面板 ── */
|
||||
.create-panel {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.create-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #409eff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.name-prefix-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.prefix-hint {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.provider-group {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.provider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.provider-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
.provider-count {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.model-check-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.model-check-item {
|
||||
margin-right: 0 !important;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.model-check-item:hover {
|
||||
border-color: #409eff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.model-check-item.is-checked {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.model-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.model-desc {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.model-price {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.provider-config {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #dcdfe6;
|
||||
}
|
||||
.create-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
background: #ecf5ff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
|
||||
204
frontend/src/views/Profile.vue
Normal file
204
frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="profile-page">
|
||||
<h3>个人设置</h3>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<el-card class="section-card">
|
||||
<template #header><span>基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户名">{{ user?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ user?.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ user?.role === 'admin' ? '管理员' : '用户' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ user?.created_at?.split('T')[0] || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- API Key 管理 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>API 密钥</span>
|
||||
<el-button type="primary" size="small" @click="showCreateDialog">+ 创建密钥</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="apiKeys.length === 0" class="empty-hint">暂无 API 密钥,点击上方按钮创建</div>
|
||||
<el-table v-else :data="apiKeys" style="width:100%">
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="key_prefix" label="前缀" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.key_prefix }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="!row.permissions" type="success" size="small">全部权限</el-tag>
|
||||
<template v-else>
|
||||
<el-tag v-for="p in row.permissions" :key="p" size="small" style="margin-right:4px">{{ p }}</el-tag>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'active' ? '生效' : '已撤销' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.expires_at ? row.expires_at.split('T')[0] : '永不过期' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后使用" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_used_at ? formatTime(row.last_used_at) : '未使用' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'active'" type="danger" link size="small" @click="revokeKey(row.id)">
|
||||
撤销
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 创建密钥对话框 -->
|
||||
<el-dialog v-model="createVisible" title="创建 API 密钥" width="500px" @closed="resetCreateForm">
|
||||
<el-form :model="createForm" label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="createForm.name" placeholder="如:生产环境、测试脚本" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间">
|
||||
<el-select v-model="createForm.expires_in_days" placeholder="永不过期" clearable style="width:100%">
|
||||
<el-option label="30 天" :value="30" />
|
||||
<el-option label="90 天" :value="90" />
|
||||
<el-option label="1 年" :value="365" />
|
||||
<el-option label="永不过期" :value="null" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!createForm.name.trim()" @click="createKey">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建成功对话框 — 展示明文密钥 -->
|
||||
<el-dialog v-model="createdVisible" title="密钥创建成功" width="520px" :close-on-click-modal="false">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
|
||||
<template #title>请立即复制并保存此密钥,关闭后无法再次查看</template>
|
||||
</el-alert>
|
||||
<el-input v-model="createdKey" readonly style="font-family:monospace;font-size:13px">
|
||||
<template #append>
|
||||
<el-button @click="copyKey">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="createdVisible = false">我已保存,关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const user = ref(userStore.user)
|
||||
|
||||
interface ApiKey {
|
||||
id: string; name: string; key_prefix: string; permissions: string[] | null
|
||||
last_used_at: string | null; expires_at: string | null; status: string; created_at: string
|
||||
}
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const createVisible = ref(false)
|
||||
const createdVisible = ref(false)
|
||||
const createdKey = ref('')
|
||||
const createForm = ref({ name: '', expires_in_days: null as number | null })
|
||||
|
||||
const baseUrl = import.meta.env.DEV ? '' : `${window.location.protocol}//${window.location.hostname}:8037`
|
||||
const token = () => localStorage.getItem('token') || ''
|
||||
|
||||
async function fetchKeys() {
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/v1/api-keys`, {
|
||||
headers: { Authorization: `Bearer ${token()}` }
|
||||
})
|
||||
if (resp.ok) apiKeys.value = await resp.json()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
if (!createForm.value.name.trim()) return
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/v1/api-keys`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
|
||||
body: JSON.stringify({
|
||||
name: createForm.value.name.trim(),
|
||||
expires_in_days: createForm.value.expires_in_days || null
|
||||
})
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json()
|
||||
ElMessage.error(err.detail || '创建失败')
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
createdKey.value = data.api_key
|
||||
createVisible.value = false
|
||||
createdVisible.value = true
|
||||
fetchKeys()
|
||||
} catch { ElMessage.error('请求失败') }
|
||||
}
|
||||
|
||||
async function revokeKey(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('撤销后该密钥立即失效,确认撤销?', '确认撤销', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/v1/api-keys/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token()}` }
|
||||
})
|
||||
if (resp.ok) {
|
||||
ElMessage.success('已撤销')
|
||||
fetchKeys()
|
||||
} else {
|
||||
const err = await resp.json()
|
||||
ElMessage.error(err.detail || '撤销失败')
|
||||
}
|
||||
} catch { ElMessage.error('请求失败') }
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard.writeText(createdKey.value).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.value = { name: '', expires_in_days: null }
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => fetchKeys())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page { max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||
.profile-page h3 { margin-bottom: 20px; }
|
||||
.section-card { margin-bottom: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.empty-hint { color: #999; text-align: center; padding: 40px 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user