feat(vue-app,flask): Vue 试验田全量对接与 Session 用户上下文统一
新增 vue-app(生成/收藏/历史/登录/优化/Android/饭菜/诗词/简历等),Flask 增加 user_context 并调整历史、生成、简历等路由;模板 base/generate 可访问性改进;补充部署说明与文档。 Made-with: Cursor
This commit is contained in:
16
vue-app/src/App.vue
Normal file
16
vue-app/src/App.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
onMounted(() => {
|
||||
void auth.refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
19
vue-app/src/api/client.ts
Normal file
19
vue-app/src/api/client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios'
|
||||
|
||||
/** 开发环境走 Vite 代理时 baseURL 为空,请求同源 /api → 转发 Flask */
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '',
|
||||
timeout: 60_000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => Promise.reject(err),
|
||||
)
|
||||
|
||||
export default client
|
||||
43
vue-app/src/api/index.ts
Normal file
43
vue-app/src/api/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import axios from 'axios'
|
||||
import type { InternalAxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
// import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 创建axios实例(指向现有Flask后端)
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:5002',
|
||||
timeout: 60000, // 与现有后端超时时间保持一致
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器(添加认证token)
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// TODO: 从auth store获取token
|
||||
// const authStore = useAuthStore()
|
||||
// if (authStore.token) {
|
||||
// config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
// }
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器(统一错误处理)
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 处理现有后端的响应格式
|
||||
if (response.data?.code && response.data.code !== 200) {
|
||||
return Promise.reject(new Error(response.data.message || '请求失败'))
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
// 统一错误处理逻辑
|
||||
console.error('API请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
26
vue-app/src/api/modules/android.ts
Normal file
26
vue-app/src/api/modules/android.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import client from '../client'
|
||||
import type { AndroidToolResponse } from '../types/android'
|
||||
|
||||
function postTool(url: string, input_text: string) {
|
||||
return client.post<AndroidToolResponse>(url, { input_text }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function analyzeAndroidCrashLog(input_text: string) {
|
||||
return postTool('/api/android/crash-log/analyze', input_text)
|
||||
}
|
||||
|
||||
export function analyzeGradleConflict(input_text: string) {
|
||||
return postTool('/api/android/gradle-conflict/analyze', input_text)
|
||||
}
|
||||
|
||||
export function generatePerfOptimize(input_text: string) {
|
||||
return postTool('/api/android/perf-optimize/generate', input_text)
|
||||
}
|
||||
|
||||
export function generateTechReview(input_text: string) {
|
||||
return postTool('/api/android/tech-review/generate', input_text)
|
||||
}
|
||||
|
||||
export function generateCodeReview(input_text: string) {
|
||||
return postTool('/api/android/code-review/generate', input_text)
|
||||
}
|
||||
28
vue-app/src/api/modules/auth.ts
Normal file
28
vue-app/src/api/modules/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import client from '../client'
|
||||
import type { LoginResponse, LogoutResponse, RegisterResponse } from '../types/auth'
|
||||
import type { CheckLoginResponse } from '../types/user'
|
||||
|
||||
export function checkLogin() {
|
||||
return client.get<CheckLoginResponse>('/api/check-login').then((r) => r.data)
|
||||
}
|
||||
|
||||
export function postLogin(login_name: string, login_pwd: string) {
|
||||
return client.post<LoginResponse>('/api/login', { login_name, login_pwd }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function postLogout() {
|
||||
return client.post<LogoutResponse>('/api/logout', {}).then((r) => r.data)
|
||||
}
|
||||
|
||||
export interface RegisterBody {
|
||||
login_name: string
|
||||
login_pwd: string
|
||||
nickname: string
|
||||
email?: string
|
||||
mobile?: string
|
||||
sex?: number
|
||||
}
|
||||
|
||||
export function postRegister(body: RegisterBody) {
|
||||
return client.post<RegisterResponse>('/api/register', body).then((r) => r.data)
|
||||
}
|
||||
31
vue-app/src/api/modules/favorite.ts
Normal file
31
vue-app/src/api/modules/favorite.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import client from '../client'
|
||||
import type { FavoritesListResponse } from '../types/favorite'
|
||||
|
||||
export interface FavoritesQuery {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export function fetchFavorites(params: FavoritesQuery) {
|
||||
return client.get<FavoritesListResponse>('/api/favorites', { params }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function deleteFavorite(id: number) {
|
||||
return client.delete<{ success: boolean; message?: string }>(`/api/favorites/${id}`).then((r) => r.data)
|
||||
}
|
||||
|
||||
export interface QuickAddFavoriteBody {
|
||||
template_id: number | null
|
||||
original_text: string
|
||||
generated_prompt: string
|
||||
system_prompt?: string | null
|
||||
category?: string | null
|
||||
}
|
||||
|
||||
export function quickAddFavorite(body: QuickAddFavoriteBody) {
|
||||
return client
|
||||
.post<{ success: boolean; message?: string; id?: number }>('/api/favorites/quick-add', body)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
17
vue-app/src/api/modules/history.ts
Normal file
17
vue-app/src/api/modules/history.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import client from '../client'
|
||||
import type { HistoryListResponse } from '../types/history'
|
||||
|
||||
export interface HistoryListQuery {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort?: string
|
||||
}
|
||||
|
||||
export function fetchHistoryList(params: HistoryListQuery) {
|
||||
return client.get<HistoryListResponse>('/api/history', { params }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function deleteHistoryItem(id: number) {
|
||||
return client.delete<{ success: boolean; message?: string }>(`/api/history/${id}`).then((r) => r.data)
|
||||
}
|
||||
36
vue-app/src/api/modules/meal.ts
Normal file
36
vue-app/src/api/modules/meal.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
MealGenerateParams,
|
||||
MealGenerateResponse,
|
||||
MealListResponse,
|
||||
MealSaveResponse,
|
||||
} from '../types/meal'
|
||||
|
||||
/** 对接 Flask:`POST /api/meal-planning/generate` */
|
||||
export function generateMealPlan(body: MealGenerateParams) {
|
||||
return client.post<MealGenerateResponse>('/api/meal-planning/generate', body).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** `POST /api/meal-planning/save` */
|
||||
export function saveMealPlan(body: {
|
||||
meal_plan_content: string
|
||||
region_type: string
|
||||
diner_count: string
|
||||
meal_type: string
|
||||
hometown: string
|
||||
preferences: string
|
||||
dietary_restrictions: string
|
||||
budget: string
|
||||
}) {
|
||||
return client.post<MealSaveResponse>('/api/meal-planning/save', body).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** `GET /api/meal-planning/list` */
|
||||
export function fetchMealPlanList(params: { page?: number; per_page?: number }) {
|
||||
return client.get<MealListResponse>('/api/meal-planning/list', { params }).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** `DELETE /api/meal-planning/:id` */
|
||||
export function deleteMealPlan(id: number) {
|
||||
return client.delete<{ success: boolean; message?: string }>(`/api/meal-planning/${id}`).then((r) => r.data)
|
||||
}
|
||||
16
vue-app/src/api/modules/optimization.ts
Normal file
16
vue-app/src/api/modules/optimization.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import client from '../client'
|
||||
import type { QuickOptimizationResponse, SmartOptimizationApiResponse } from '../types/optimization'
|
||||
|
||||
/** 两阶段专家级优化:`POST /api/smart-prompt-optimization/generate` */
|
||||
export function generateSmartOptimization(body: { input_text: string }) {
|
||||
return client
|
||||
.post<SmartOptimizationApiResponse>('/api/smart-prompt-optimization/generate', body)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 通用模板快速优化:`POST /api/prompt-optimization/generate` */
|
||||
export function generateQuickOptimization(body: { input_text: string }) {
|
||||
return client
|
||||
.post<QuickOptimizationResponse>('/api/prompt-optimization/generate', body)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
26
vue-app/src/api/modules/poetry.ts
Normal file
26
vue-app/src/api/modules/poetry.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
PoetryAnalyzeRequest,
|
||||
PoetryAnalyzeResponse,
|
||||
PoetryFavoriteAddResponse,
|
||||
PoetryFavoritesListResponse,
|
||||
} from '../types/poetry'
|
||||
|
||||
/** Flask 蓝图前缀 `/poetry`,非 `/api`,开发环境需 Vite 代理 `/poetry` */
|
||||
export function analyzePoetry(body: PoetryAnalyzeRequest) {
|
||||
return client.post<PoetryAnalyzeResponse>('/poetry/analyze', body).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function fetchPoetryFavorites(params: { page?: number; per_page?: number; search?: string }) {
|
||||
return client.get<PoetryFavoritesListResponse>('/poetry/favorites/list', { params }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function addPoetryFavorite(body: Record<string, unknown>) {
|
||||
return client.post<PoetryFavoriteAddResponse>('/poetry/favorites/add', body).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function deletePoetryFavorite(id: number) {
|
||||
return client
|
||||
.delete<{ success: boolean; message?: string }>(`/poetry/favorites/${id}`)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
10
vue-app/src/api/modules/profile.ts
Normal file
10
vue-app/src/api/modules/profile.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import client from '../client'
|
||||
import type { ProfileGetResponse, ProfileUpdateBody, ProfileUpdateResponse } from '../types/profile'
|
||||
|
||||
export function fetchProfile() {
|
||||
return client.get<ProfileGetResponse>('/api/profile').then((r) => r.data)
|
||||
}
|
||||
|
||||
export function updateProfile(body: ProfileUpdateBody) {
|
||||
return client.put<ProfileUpdateResponse>('/api/profile', body).then((r) => r.data)
|
||||
}
|
||||
19
vue-app/src/api/modules/prompt.ts
Normal file
19
vue-app/src/api/modules/prompt.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
GenerateMetaResponse,
|
||||
GeneratePromptResponse,
|
||||
TemplatesByCategoryResponse,
|
||||
} from '../types/template'
|
||||
|
||||
export function fetchGenerateMeta() {
|
||||
return client.get<GenerateMetaResponse>('/api/generate/meta').then((r) => r.data)
|
||||
}
|
||||
|
||||
export function fetchTemplatesByCategory(category: string) {
|
||||
const path = '/api/templates/' + encodeURIComponent(category)
|
||||
return client.get<TemplatesByCategoryResponse>(path).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function generatePrompt(body: { input_text: string; template_id: number | null }) {
|
||||
return client.post<GeneratePromptResponse>('/api/prompt/generate', body).then((r) => r.data)
|
||||
}
|
||||
45
vue-app/src/api/modules/resume.ts
Normal file
45
vue-app/src/api/modules/resume.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
ResumeDetailResponse,
|
||||
ResumeGenerateResponse,
|
||||
ResumeListResponse,
|
||||
ResumeOptType,
|
||||
ResumeSaveResponse,
|
||||
} from '../types/resume'
|
||||
|
||||
export function generateResumeOptimization(body: {
|
||||
opt_type: ResumeOptType
|
||||
original_content: string
|
||||
job_description?: string
|
||||
}) {
|
||||
return client
|
||||
.post<ResumeGenerateResponse>('/api/resume-optimization/generate', body)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
export function saveResumeOptimization(body: {
|
||||
opt_type: ResumeOptType
|
||||
original_content: string
|
||||
job_description?: string
|
||||
optimized_content: string
|
||||
}) {
|
||||
return client.post<ResumeSaveResponse>('/api/resume-optimization/save', body).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function fetchResumeOptimizationList(params: { page?: number; per_page?: number }) {
|
||||
return client
|
||||
.get<ResumeListResponse>('/api/resume-optimization/list', { params })
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
export function fetchResumeOptimizationDetail(id: number) {
|
||||
return client
|
||||
.get<ResumeDetailResponse>(`/api/resume-optimization/${id}`)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
export function deleteResumeOptimization(id: number) {
|
||||
return client
|
||||
.delete<{ success: boolean; message?: string }>(`/api/resume-optimization/${id}`)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
53
vue-app/src/api/modules/wx.ts
Normal file
53
vue-app/src/api/modules/wx.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import apiClient from '../index'
|
||||
import type { ApiResponse, PaginatedResponse } from '../types'
|
||||
|
||||
export interface WxPrompt {
|
||||
id: number
|
||||
input_text: string
|
||||
generated_text: string
|
||||
created_at: string
|
||||
template_id?: number
|
||||
template_name?: string
|
||||
}
|
||||
|
||||
export const wxApi = {
|
||||
// 获取微信用户提示词列表
|
||||
getWxPrompts(params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
template_id?: number
|
||||
}): Promise<ApiResponse<PaginatedResponse<WxPrompt>>> {
|
||||
return apiClient.get('/api/wx/prompts', { params })
|
||||
},
|
||||
|
||||
// 获取微信用户提示词详情
|
||||
getWxPromptDetail(promptId: number): Promise<ApiResponse<{ prompt: WxPrompt }>> {
|
||||
return apiClient.get(`/api/wx/prompt/${promptId}`)
|
||||
},
|
||||
|
||||
// 搜索微信用户提示词
|
||||
searchWxPrompts(keyword: string, page = 1, size = 10): Promise<ApiResponse<any>> {
|
||||
return apiClient.get('/api/wx/prompts/search', {
|
||||
params: { keyword, page, size }
|
||||
})
|
||||
},
|
||||
|
||||
// 获取微信用户提示词数量
|
||||
getWxPromptsCount(): Promise<ApiResponse<{ count: number }>> {
|
||||
return apiClient.get('/api/wx/prompts/count')
|
||||
},
|
||||
|
||||
// 删除微信用户提示词
|
||||
deleteWxPrompt(promptId: number): Promise<ApiResponse> {
|
||||
return apiClient.delete(`/api/wx/prompt/${promptId}`)
|
||||
},
|
||||
|
||||
// 获取微信模板列表
|
||||
getWxTemplates(params?: {
|
||||
category?: string
|
||||
industry?: string
|
||||
profession?: string
|
||||
}): Promise<ApiResponse<any>> {
|
||||
return apiClient.get('/api/wx/templates', { params })
|
||||
}
|
||||
}
|
||||
7
vue-app/src/api/types/android.ts
Normal file
7
vue-app/src/api/types/android.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface AndroidToolResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
result: string
|
||||
}
|
||||
}
|
||||
25
vue-app/src/api/types/auth.ts
Normal file
25
vue-app/src/api/types/auth.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
user?: {
|
||||
uid: number
|
||||
login_name: string
|
||||
nickname: string | null
|
||||
email?: string | null
|
||||
mobile?: string | null
|
||||
sex?: number
|
||||
avatar?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
user_id?: number
|
||||
nickname?: string | null
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
25
vue-app/src/api/types/favorite.ts
Normal file
25
vue-app/src/api/types/favorite.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface FavoriteItem {
|
||||
id: number
|
||||
template_id: number | null
|
||||
original_text: string
|
||||
generated_prompt: string
|
||||
system_prompt: string | null
|
||||
category: string | null
|
||||
industry: string | null
|
||||
profession: string | null
|
||||
tags: string | null
|
||||
notes: string | null
|
||||
created_time: string | null
|
||||
updated_time: string | null
|
||||
}
|
||||
|
||||
export interface FavoritesListResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: FavoriteItem[]
|
||||
total?: number
|
||||
pages?: number
|
||||
current_page?: number
|
||||
has_prev?: boolean
|
||||
has_next?: boolean
|
||||
}
|
||||
31
vue-app/src/api/types/history.ts
Normal file
31
vue-app/src/api/types/history.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface HistoryItem {
|
||||
id: number
|
||||
user_id: number
|
||||
wx_user_id: number | null
|
||||
original_input: string
|
||||
generated_prompt: string
|
||||
template_id: number | null
|
||||
template_name: string | null
|
||||
generation_time: number | null
|
||||
satisfaction_rating: number | null
|
||||
tags: unknown[]
|
||||
is_favorite: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface HistoryListResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
history: HistoryItem[]
|
||||
pagination: {
|
||||
page: number
|
||||
per_page: number
|
||||
total: number
|
||||
pages: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
101
vue-app/src/api/types/index.ts
Normal file
101
vue-app/src/api/types/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// 基础API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
total: number
|
||||
pages: number
|
||||
current_page: number
|
||||
data: T[]
|
||||
}
|
||||
|
||||
// 用户相关类型
|
||||
export interface User {
|
||||
uid: number
|
||||
nickname: string
|
||||
mobile: string
|
||||
email: string
|
||||
sex: number
|
||||
avatar: string
|
||||
login_name: string
|
||||
status: number
|
||||
updated_time: string
|
||||
created_time: string
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
login_name: string
|
||||
login_pwd: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
login_name: string
|
||||
login_pwd: string
|
||||
nickname: string
|
||||
email?: string
|
||||
mobile?: string
|
||||
sex?: number
|
||||
}
|
||||
|
||||
// 提示词模板类型
|
||||
export interface PromptTemplate {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
industry?: string
|
||||
profession?: string
|
||||
sub_category?: string
|
||||
system_prompt: string
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 提示词生成请求
|
||||
export interface PromptGenerateRequest {
|
||||
input_text: string
|
||||
template_id?: number
|
||||
}
|
||||
|
||||
// 提示词生成响应
|
||||
export interface PromptGenerateResponse {
|
||||
generated_text: string
|
||||
generation_time?: number
|
||||
}
|
||||
|
||||
// 收藏类型
|
||||
export interface Favorite {
|
||||
id: number
|
||||
template_id?: number
|
||||
original_text: string
|
||||
generated_prompt: string
|
||||
system_prompt?: string
|
||||
category?: string
|
||||
industry?: string
|
||||
profession?: string
|
||||
tags?: string[]
|
||||
notes?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 微信用户类型
|
||||
export interface WxUser {
|
||||
id: number
|
||||
openid: string
|
||||
nickname?: string
|
||||
avatar_url?: string
|
||||
gender?: number
|
||||
country?: string
|
||||
province?: string
|
||||
city?: string
|
||||
language?: string
|
||||
phone?: string
|
||||
is_active: boolean
|
||||
last_login: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
64
vue-app/src/api/types/meal.ts
Normal file
64
vue-app/src/api/types/meal.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export interface MealGenerateParams {
|
||||
region_type?: string
|
||||
diner_count?: string
|
||||
meal_type?: string
|
||||
hometown: string
|
||||
preferences?: string
|
||||
dietary_restrictions?: string
|
||||
budget?: string
|
||||
}
|
||||
|
||||
export interface MealGenerateResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
meal_plan: string
|
||||
parameters: {
|
||||
region_type: string
|
||||
diner_count: string
|
||||
meal_type: string
|
||||
hometown: string
|
||||
preferences: string
|
||||
dietary_restrictions: string
|
||||
budget: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MealPlanRow {
|
||||
id: number
|
||||
region_type: string
|
||||
diner_count: string
|
||||
meal_type: string
|
||||
hometown: string
|
||||
preferences: string
|
||||
dietary_restrictions: string
|
||||
budget: string
|
||||
meal_plan_content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MealListResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
plans: MealPlanRow[]
|
||||
pagination: {
|
||||
page: number
|
||||
pages: number
|
||||
per_page: number
|
||||
total: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MealSaveResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
meal_plan_id: number
|
||||
created_at: string
|
||||
}
|
||||
}
|
||||
17
vue-app/src/api/types/optimization.ts
Normal file
17
vue-app/src/api/types/optimization.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Flask 智能优化接口使用 code/message/data,与 success 风格不同 */
|
||||
export interface SmartOptimizationApiResponse {
|
||||
code: number
|
||||
message: string
|
||||
data: null | {
|
||||
intent_analysis: Record<string, unknown>
|
||||
generated_prompt: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuickOptimizationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
generated_text: string
|
||||
}
|
||||
}
|
||||
50
vue-app/src/api/types/poetry.ts
Normal file
50
vue-app/src/api/types/poetry.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface PoetryAnalyzeRequest {
|
||||
poetry_title: string
|
||||
author: string
|
||||
dynasty?: string
|
||||
translation_style?: string
|
||||
interpretation_depth?: string
|
||||
purpose?: string
|
||||
target_audience?: string
|
||||
translation_quality?: string
|
||||
annotation_detail?: string
|
||||
interpretation_level?: string
|
||||
}
|
||||
|
||||
export interface PoetryAnalyzeResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
result?: string
|
||||
poetry_info?: {
|
||||
title: string
|
||||
author: string
|
||||
dynasty: string
|
||||
}
|
||||
error_detail?: string | null
|
||||
}
|
||||
|
||||
export interface PoetryFavoriteSummary {
|
||||
id: number
|
||||
poetry_title: string
|
||||
author: string
|
||||
dynasty: string | null
|
||||
purpose: string | null
|
||||
target_audience: string | null
|
||||
notes: string | null
|
||||
created_time: string | null
|
||||
}
|
||||
|
||||
export interface PoetryFavoritesListResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: PoetryFavoriteSummary[]
|
||||
total?: number
|
||||
pages?: number
|
||||
current_page?: number
|
||||
}
|
||||
|
||||
export interface PoetryFavoriteAddResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
id?: number
|
||||
}
|
||||
29
vue-app/src/api/types/profile.ts
Normal file
29
vue-app/src/api/types/profile.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface ProfileUser {
|
||||
uid: number
|
||||
login_name: string
|
||||
nickname: string | null
|
||||
email: string | null
|
||||
mobile: string | null
|
||||
sex: number
|
||||
avatar: string | null
|
||||
status?: number
|
||||
created_time?: string | null
|
||||
}
|
||||
|
||||
export interface ProfileGetResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: ProfileUser
|
||||
}
|
||||
|
||||
export interface ProfileUpdateBody {
|
||||
nickname?: string
|
||||
email?: string
|
||||
mobile?: string
|
||||
sex?: number
|
||||
}
|
||||
|
||||
export interface ProfileUpdateResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
56
vue-app/src/api/types/resume.ts
Normal file
56
vue-app/src/api/types/resume.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type ResumeOptType = 'resume' | 'cover_letter'
|
||||
|
||||
export interface ResumeGenerateResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
optimized: string
|
||||
opt_type: ResumeOptType
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResumeSaveResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
id: number
|
||||
created_at: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResumeListItem {
|
||||
id: number
|
||||
opt_type: string
|
||||
original_content: string
|
||||
optimized_content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ResumeListResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
plans: ResumeListItem[]
|
||||
pagination: {
|
||||
page: number
|
||||
pages: number
|
||||
per_page: number
|
||||
total: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResumeDetailResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
id: number
|
||||
opt_type: string
|
||||
original_content: string
|
||||
job_description: string | null
|
||||
optimized_content: string
|
||||
created_at: string
|
||||
}
|
||||
}
|
||||
37
vue-app/src/api/types/template.ts
Normal file
37
vue-app/src/api/types/template.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface PromptTemplateItem {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
industry: string | null
|
||||
profession: string | null
|
||||
sub_category: string | null
|
||||
system_prompt: string
|
||||
sample_input: string
|
||||
}
|
||||
|
||||
export interface TemplatesByCategoryResponse {
|
||||
success: boolean
|
||||
templates: PromptTemplateItem[]
|
||||
count: number
|
||||
quick_start: { id: number; name: string; sample_input: string }[]
|
||||
}
|
||||
|
||||
export interface GenerateMetaResponse {
|
||||
success: boolean
|
||||
categories: string[]
|
||||
industries: string[]
|
||||
professions: string[]
|
||||
sub_categories: string[]
|
||||
}
|
||||
|
||||
export interface GeneratePromptResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
generated_text?: string
|
||||
prompt?: {
|
||||
id: number
|
||||
input_text: string
|
||||
generated_text: string
|
||||
}
|
||||
}
|
||||
9
vue-app/src/api/types/user.ts
Normal file
9
vue-app/src/api/types/user.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface CheckLoginResponse {
|
||||
success: boolean
|
||||
logged_in: boolean
|
||||
user?: {
|
||||
user_id: number
|
||||
nickname?: string | null
|
||||
login_name?: string | null
|
||||
}
|
||||
}
|
||||
17
vue-app/src/api/utils/interceptors.ts
Normal file
17
vue-app/src/api/utils/interceptors.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// API拦截器工具函数
|
||||
// 这里可以放置通用的拦截器逻辑
|
||||
|
||||
export const requestLogger = (config: any) => {
|
||||
console.log(`[API请求] ${config.method?.toUpperCase()} ${config.url}`)
|
||||
return config
|
||||
}
|
||||
|
||||
export const responseLogger = (response: any) => {
|
||||
console.log(`[API响应] ${response.status} ${response.config.url}`)
|
||||
return response
|
||||
}
|
||||
|
||||
export const errorHandler = (error: any) => {
|
||||
console.error('[API错误]', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
BIN
vue-app/src/assets/hero.png
Normal file
BIN
vue-app/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
13
vue-app/src/assets/styles/_variables.scss
Normal file
13
vue-app/src/assets/styles/_variables.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
// 与 Flask base.html 主题对齐(vue重构方案 · 样式迁移)
|
||||
$primary-color: #1e3a8a;
|
||||
$primary-light: #3b82f6;
|
||||
$primary-dark: #1e40af;
|
||||
$secondary-color: #6366f1;
|
||||
$accent-color: #8b5cf6;
|
||||
|
||||
$text-color: #0f172a;
|
||||
$text-secondary: #334155;
|
||||
$text-muted: #64748b;
|
||||
|
||||
$border-color: #e2e8f0;
|
||||
$gray-100: #f1f5f9;
|
||||
27
vue-app/src/assets/styles/main.scss
Normal file
27
vue-app/src/assets/styles/main.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
:root {
|
||||
--app-bg: #f8fafc;
|
||||
--primary-color: #{$primary-color};
|
||||
--text-color: #{$text-color};
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
system-ui,
|
||||
sans-serif;
|
||||
color: $text-color;
|
||||
background: var(--app-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
1
vue-app/src/assets/vite.svg
Normal file
1
vue-app/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
vue-app/src/assets/vue.svg
Normal file
1
vue-app/src/assets/vue.svg
Normal 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
vue-app/src/auto-imports.d.ts
vendored
Normal file
10
vue-app/src/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const ElMessage: typeof import('element-plus/es').ElMessage
|
||||
}
|
||||
49
vue-app/src/components.d.ts
vendored
Normal file
49
vue-app/src/components.d.ts
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
244
vue-app/src/components/layout/AppHeader.vue
Normal file
244
vue-app/src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const flaskOrigin = import.meta.env.VITE_FLASK_ORIGIN || ''
|
||||
|
||||
const legacyHomeUrl = computed(() => flaskOrigin || '/')
|
||||
|
||||
const loginRoute = computed(() => ({
|
||||
name: 'login' as const,
|
||||
query: { redirect: route.fullPath },
|
||||
}))
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await auth.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
} catch {
|
||||
ElMessage.error('退出失败')
|
||||
}
|
||||
}
|
||||
|
||||
type NavRouteName =
|
||||
| 'home'
|
||||
| 'favorites'
|
||||
| 'generate'
|
||||
| 'history'
|
||||
| 'profile'
|
||||
| 'meal-planning'
|
||||
| 'poetry'
|
||||
| 'optimization'
|
||||
| 'android-tools'
|
||||
| 'resume-optimization'
|
||||
|
||||
function go(name: NavRouteName) {
|
||||
router.push({ name })
|
||||
}
|
||||
|
||||
function onMoreCommand(cmd: string) {
|
||||
if (cmd === 'flask-legacy') {
|
||||
window.location.href = legacyHomeUrl.value || '/'
|
||||
return
|
||||
}
|
||||
go(cmd as NavRouteName)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<button type="button" class="brand" @click="go('home')">
|
||||
<span class="brand-mark">提示词大师</span>
|
||||
<span class="brand-tag">Vue 试验田</span>
|
||||
</button>
|
||||
|
||||
<nav class="nav" aria-label="主导航">
|
||||
<button type="button" class="nav-link" @click="go('home')">首页</button>
|
||||
<button type="button" class="nav-link" @click="go('generate')">生成</button>
|
||||
<button type="button" class="nav-link" @click="go('optimization')">优化</button>
|
||||
<button type="button" class="nav-link" @click="go('favorites')">收藏</button>
|
||||
<button type="button" class="nav-link" @click="go('history')">历史</button>
|
||||
<el-dropdown trigger="click" placement="bottom-start" @command="onMoreCommand">
|
||||
<button type="button" class="nav-link nav-more" aria-haspopup="menu" aria-label="更多功能">
|
||||
更多
|
||||
<span class="caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="resume-optimization">简历 / 求职信</el-dropdown-item>
|
||||
<el-dropdown-item command="meal-planning">饭菜规划</el-dropdown-item>
|
||||
<el-dropdown-item command="poetry">古诗词</el-dropdown-item>
|
||||
<el-dropdown-item command="android-tools">Android 工具</el-dropdown-item>
|
||||
<el-dropdown-item command="flask-legacy" divided>经典版生成页</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<button type="button" class="nav-link" @click="go('profile')">资料</button>
|
||||
</nav>
|
||||
|
||||
<div class="user">
|
||||
<template v-if="!auth.ready">
|
||||
<span class="muted">…</span>
|
||||
</template>
|
||||
<template v-else-if="auth.loggedIn">
|
||||
<span class="user-name">{{ auth.nickname || '已登录' }}</span>
|
||||
<button type="button" class="btn-logout" @click="onLogout">退出</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link class="btn-login" :to="loginRoute">登录</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-bottom: 1px solid $border-color;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
background: linear-gradient(135deg, $secondary-color, $accent-color);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.brand-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: $text-secondary;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
background: rgba(30, 58, 138, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.caret {
|
||||
font-size: 0.7em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.nav :deep(.el-dropdown) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $text-color;
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $text-muted;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, $secondary-color, $accent-color);
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
border: 1px solid $border-color;
|
||||
background: #fff;
|
||||
color: $text-secondary;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $gray-100;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
vue-app/src/layouts/AuthLayout.vue
Normal file
16
vue-app/src/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="auth-shell">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
background: linear-gradient(160deg, #f1f5f9 0%, #e0e7ff 45%, #f8fafc 100%);
|
||||
}
|
||||
</style>
|
||||
29
vue-app/src/layouts/DefaultLayout.vue
Normal file
29
vue-app/src/layouts/DefaultLayout.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<AppHeader />
|
||||
<main class="app-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--app-bg, #f8fafc);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
}
|
||||
</style>
|
||||
10
vue-app/src/main.ts
Normal file
10
vue-app/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import '@/assets/styles/main.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
116
vue-app/src/router/index.ts
Normal file
116
vue-app/src/router/index.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
component: () => import('@/layouts/AuthLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'login',
|
||||
component: () => import('@/views/Auth/LoginView.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
component: () => import('@/layouts/AuthLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'register',
|
||||
component: () => import('@/views/Auth/RegisterView.vue'),
|
||||
meta: { title: '注册' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: DefaultLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
{
|
||||
path: 'favorites',
|
||||
name: 'favorites',
|
||||
component: () => import('@/views/FavoritesView.vue'),
|
||||
meta: { title: '收藏' },
|
||||
},
|
||||
{
|
||||
path: 'generate',
|
||||
name: 'generate',
|
||||
component: () => import('@/views/GenerateView.vue'),
|
||||
meta: { title: '提示词生成' },
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
name: 'history',
|
||||
component: () => import('@/views/HistoryView.vue'),
|
||||
meta: { title: '优化历史' },
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
meta: { title: '个人资料', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'meal-planning',
|
||||
name: 'meal-planning',
|
||||
component: () => import('@/views/MealPlanningView.vue'),
|
||||
meta: { title: '饭菜规划' },
|
||||
},
|
||||
{
|
||||
path: 'poetry',
|
||||
name: 'poetry',
|
||||
component: () => import('@/views/PoetryView.vue'),
|
||||
meta: { title: '古诗词' },
|
||||
},
|
||||
{
|
||||
path: 'optimization',
|
||||
name: 'optimization',
|
||||
component: () => import('@/views/OptimizationView.vue'),
|
||||
meta: { title: '提示词优化' },
|
||||
},
|
||||
{
|
||||
path: 'android-tools',
|
||||
name: 'android-tools',
|
||||
component: () => import('@/views/AndroidToolsView.vue'),
|
||||
meta: { title: 'Android 工具' },
|
||||
},
|
||||
{
|
||||
path: 'resume-optimization',
|
||||
name: 'resume-optimization',
|
||||
component: () => import('@/views/ResumeOptimizationView.vue'),
|
||||
meta: { title: '简历优化' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (!to.meta.requiresAuth) return true
|
||||
const auth = useAuthStore()
|
||||
if (!auth.ready) await auth.refresh()
|
||||
if (!auth.loggedIn) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const base = import.meta.env.VITE_APP_TITLE ?? '提示词大师'
|
||||
document.title = to.meta.title ? `${base} · ${to.meta.title}` : base
|
||||
})
|
||||
|
||||
export default router
|
||||
56
vue-app/src/stores/auth.ts
Normal file
56
vue-app/src/stores/auth.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { checkLogin, postLogin, postLogout, postRegister, type RegisterBody } from '@/api/modules/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const loggedIn = ref(false)
|
||||
const nickname = ref<string | null>(null)
|
||||
const userId = ref<number | null>(null)
|
||||
const ready = ref(false)
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await checkLogin()
|
||||
loggedIn.value = Boolean(r.logged_in)
|
||||
if (r.user) {
|
||||
userId.value = r.user.user_id
|
||||
nickname.value = r.user.nickname ?? r.user.login_name ?? null
|
||||
} else {
|
||||
userId.value = null
|
||||
nickname.value = null
|
||||
}
|
||||
} catch {
|
||||
loggedIn.value = false
|
||||
userId.value = null
|
||||
nickname.value = null
|
||||
} finally {
|
||||
ready.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function login(login_name: string, login_pwd: string) {
|
||||
const r = await postLogin(login_name, login_pwd)
|
||||
if (!r.success) {
|
||||
throw new Error(r.message || '登录失败')
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await postLogout()
|
||||
} finally {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function register(body: RegisterBody) {
|
||||
const r = await postRegister(body)
|
||||
if (!r.success) {
|
||||
throw new Error(r.message || '注册失败')
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return { loggedIn, nickname, userId, ready, refresh, login, logout, register }
|
||||
})
|
||||
237
vue-app/src/views/AndroidToolsView.vue
Normal file
237
vue-app/src/views/AndroidToolsView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
analyzeAndroidCrashLog,
|
||||
analyzeGradleConflict,
|
||||
generateCodeReview,
|
||||
generatePerfOptimize,
|
||||
generateTechReview,
|
||||
} from '@/api/modules/android'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const tab = ref('crash')
|
||||
|
||||
const inputs = reactive({
|
||||
crash: '',
|
||||
gradle: '',
|
||||
perf: '',
|
||||
tech: '',
|
||||
code: '',
|
||||
})
|
||||
|
||||
const results = reactive({
|
||||
crash: '',
|
||||
gradle: '',
|
||||
perf: '',
|
||||
tech: '',
|
||||
code: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function run(kind: keyof typeof inputs) {
|
||||
const text = inputs[kind].trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('请先输入内容')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
results[kind] = ''
|
||||
try {
|
||||
let res
|
||||
switch (kind) {
|
||||
case 'crash':
|
||||
res = await analyzeAndroidCrashLog(text)
|
||||
break
|
||||
case 'gradle':
|
||||
res = await analyzeGradleConflict(text)
|
||||
break
|
||||
case 'perf':
|
||||
res = await generatePerfOptimize(text)
|
||||
break
|
||||
case 'tech':
|
||||
res = await generateTechReview(text)
|
||||
break
|
||||
case 'code':
|
||||
res = await generateCodeReview(text)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '处理失败')
|
||||
return
|
||||
}
|
||||
results[kind] = res.data?.result ?? ''
|
||||
ElMessage.success('完成(已写入优化历史)')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端可用')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(kind: keyof typeof results) {
|
||||
const t = results[kind]
|
||||
if (!t) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(t)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="android-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">Android 工程师工具</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">对接 <code>/api/android/*</code>,结果会写入站内「优化历史」。</p>
|
||||
|
||||
<el-tabs v-model="tab" class="tabs">
|
||||
<el-tab-pane label="崩溃 / 日志解读" name="crash">
|
||||
<el-card v-loading="loading && tab === 'crash'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.crash"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="粘贴 Stack Trace、Logcat 异常片段…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('crash')">分析</el-button>
|
||||
<el-button v-if="results.crash" @click="copy('crash')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.crash" class="result-pre">{{ results.crash }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Gradle 依赖冲突" name="gradle">
|
||||
<el-card v-loading="loading && tab === 'gradle'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.gradle"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="粘贴 Gradle 报错、依赖冲突或 dependencies 输出片段…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('gradle')">分析</el-button>
|
||||
<el-button v-if="results.gradle" @click="copy('gradle')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.gradle" class="result-pre">{{ results.gradle }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="性能优化建议" name="perf">
|
||||
<el-card v-loading="loading && tab === 'perf'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.perf"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="描述启动慢、卡顿、内存、包体、耗电等场景…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('perf')">生成建议</el-button>
|
||||
<el-button v-if="results.perf" @click="copy('perf')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.perf" class="result-pre">{{ results.perf }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="技术方案评审" name="tech">
|
||||
<el-card v-loading="loading && tab === 'tech'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.tech"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="粘贴或描述技术方案、选型、改造设计…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('tech')">生成评审要点</el-button>
|
||||
<el-button v-if="results.tech" @click="copy('tech')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.tech" class="result-pre">{{ results.tech }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Code Review 清单" name="code">
|
||||
<el-card v-loading="loading && tab === 'code'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.code"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="说明模块类型(如网络层、自定义 View)或粘贴代码片段…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('code')">生成清单</el-button>
|
||||
<el-button v-if="results.code" @click="copy('code')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.code" class="result-pre">{{ results.code }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.android-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: $gray-100;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
91
vue-app/src/views/Auth/LoginView.vue
Normal file
91
vue-app/src/views/Auth/LoginView.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loginName = ref('')
|
||||
const loginPwd = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const redirectTo = computed(() => {
|
||||
const r = route.query.redirect
|
||||
if (typeof r === 'string' && r.startsWith('/') && !r.startsWith('/login')) return r
|
||||
return '/'
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
const name = loginName.value.trim()
|
||||
const pwd = loginPwd.value
|
||||
if (!name || !pwd) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(name, pwd)
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="auth-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="head">登录</div>
|
||||
</template>
|
||||
<el-form label-position="top" @submit.prevent="onSubmit">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="loginName" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="loginPwd" type="password" show-password autocomplete="current-password" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="foot">
|
||||
<router-link :to="{ name: 'register', query: route.query }">没有账号?注册</router-link>
|
||||
<router-link :to="{ name: 'home' }">返回首页</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.head {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
102
vue-app/src/views/Auth/RegisterView.vue
Normal file
102
vue-app/src/views/Auth/RegisterView.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loginName = ref('')
|
||||
const loginPwd = ref('')
|
||||
const loginPwd2 = ref('')
|
||||
const nickname = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
const name = loginName.value.trim()
|
||||
const pwd = loginPwd.value
|
||||
const pwd2 = loginPwd2.value
|
||||
const nick = nickname.value.trim()
|
||||
if (!name || !pwd || !nick) {
|
||||
ElMessage.warning('请填写用户名、密码与昵称')
|
||||
return
|
||||
}
|
||||
if (pwd !== pwd2) {
|
||||
ElMessage.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register({ login_name: name, login_pwd: pwd, nickname: nick })
|
||||
ElMessage.success('注册成功')
|
||||
const r = route.query.redirect
|
||||
const path =
|
||||
typeof r === 'string' && r.startsWith('/') && !r.startsWith('/register') ? r : '/'
|
||||
await router.replace(path)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="auth-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="head">注册</div>
|
||||
</template>
|
||||
<el-form label-position="top" @submit.prevent="onSubmit">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="nickname" autocomplete="nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="loginName" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="loginPwd" type="password" show-password autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="loginPwd2" type="password" show-password autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="foot">
|
||||
<router-link :to="{ name: 'login', query: route.query }">已有账号?登录</router-link>
|
||||
<router-link :to="{ name: 'home' }">返回首页</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.head {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
vue-app/src/views/FavoritesView.vue
Normal file
167
vue-app/src/views/FavoritesView.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { fetchFavorites, deleteFavorite } from '@/api/modules/favorite'
|
||||
import type { FavoriteItem } from '@/api/types/favorite'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<FavoriteItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
search: '',
|
||||
category: 'all',
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchFavorites({
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
search: query.search.trim() || undefined,
|
||||
category: query.category === 'all' ? undefined : query.category,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '加载失败')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
list.value = res.data ?? []
|
||||
total.value = res.total ?? 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('网络错误,请确认 Flask 已启动且 Vite 代理指向正确端口')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function onDelete(row: FavoriteItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除收藏 #${row.id} ?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteFavorite(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('删除请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
|
||||
watch(
|
||||
() => [query.page, query.per_page],
|
||||
() => load(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="favorites-page">
|
||||
<el-card shadow="never" class="panel">
|
||||
<template #header>
|
||||
<div class="panel-head">
|
||||
<span>我的收藏</span>
|
||||
<el-tag type="info" size="small">试点模块 · 对接 /api/favorites</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" class="filters" @submit.prevent="onSearch">
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="query.search"
|
||||
placeholder="原文 / 生成内容 / 备注"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="query.category" style="width: 140px" @change="onSearch">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="通用" value="通用" />
|
||||
<el-option label="考试" value="考试" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe empty-text="暂无收藏" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="category" label="分类" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="original_text" label="原始需求" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="generated_prompt" label="生成提示词" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="created_time" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="onDelete(row)"> 删除 </el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.per_page"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.favorites-page {
|
||||
.panel {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
504
vue-app/src/views/GenerateView.vue
Normal file
504
vue-app/src/views/GenerateView.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchGenerateMeta, fetchTemplatesByCategory, generatePrompt } from '@/api/modules/prompt'
|
||||
import { quickAddFavorite } from '@/api/modules/favorite'
|
||||
import type { PromptTemplateItem } from '@/api/types/template'
|
||||
|
||||
const metaLoading = ref(true)
|
||||
const templatesLoading = ref(false)
|
||||
const generating = ref(false)
|
||||
|
||||
const categories = ref<string[]>([])
|
||||
/** 全库维度(与 /api/generate/meta 一致);当当前分类模板未打某一维标签时,该维下拉回退到此列表,避免只剩「全部」 */
|
||||
const globalIndustries = ref<string[]>([])
|
||||
const globalProfessions = ref<string[]>([])
|
||||
const globalSubCategories = ref<string[]>([])
|
||||
|
||||
const activeCategory = ref('通用')
|
||||
const industry = ref('all')
|
||||
const profession = ref('all')
|
||||
const subCategory = ref('all')
|
||||
|
||||
const templates = ref<PromptTemplateItem[]>([])
|
||||
const quickStart = ref<{ id: number; name: string; sample_input: string }[]>([])
|
||||
const selectedTemplateId = ref<number | undefined>(undefined)
|
||||
|
||||
const inputText = ref('')
|
||||
const autoFillSample = ref(true)
|
||||
|
||||
const result = ref<{ id: number; input_text: string; generated_text: string } | null>(null)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const selectedTemplate = computed(
|
||||
() => templates.value.find((t) => t.id === selectedTemplateId.value) ?? null,
|
||||
)
|
||||
|
||||
const favoriting = ref(false)
|
||||
|
||||
/** 筛选项必须来自「当前分类」已加载模板;meta 全库维度会导致选到本分类不存在的值(如 通用 + API测试) */
|
||||
function sortedUnique(values: (string | null | undefined)[]) {
|
||||
return [...new Set(values.filter((x): x is string => Boolean(x && String(x).trim())))].sort((a, b) =>
|
||||
a.localeCompare(b, 'zh-CN'),
|
||||
)
|
||||
}
|
||||
|
||||
function optionsForDimension(fromTemplates: string[], global: string[]) {
|
||||
if (fromTemplates.length > 0) return fromTemplates
|
||||
if (templates.value.length > 0 && global.length > 0) return global
|
||||
return fromTemplates
|
||||
}
|
||||
|
||||
const availableIndustries = computed(() =>
|
||||
optionsForDimension(sortedUnique(templates.value.map((t) => t.industry)), globalIndustries.value),
|
||||
)
|
||||
const availableProfessions = computed(() =>
|
||||
optionsForDimension(sortedUnique(templates.value.map((t) => t.profession)), globalProfessions.value),
|
||||
)
|
||||
const availableSubCategories = computed(() =>
|
||||
optionsForDimension(sortedUnique(templates.value.map((t) => t.sub_category)), globalSubCategories.value),
|
||||
)
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return templates.value.filter((t) => {
|
||||
if (industry.value !== 'all' && t.industry !== industry.value) return false
|
||||
if (profession.value !== 'all' && t.profession !== profession.value) return false
|
||||
if (subCategory.value !== 'all' && t.sub_category !== subCategory.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const emptyTemplateHint = computed(() => {
|
||||
if (!templates.value.length) return '当前分类暂无模板'
|
||||
const narrowed =
|
||||
industry.value !== 'all' || profession.value !== 'all' || subCategory.value !== 'all'
|
||||
if (narrowed) {
|
||||
return '当前分类下没有符合筛选条件的模板,请重置筛选或换一项'
|
||||
}
|
||||
return '无匹配模板,请调整筛选或切换场景'
|
||||
})
|
||||
|
||||
/** 当前分类模板在某一维全部为空时,下拉使用全库列表,便于选到「本分类下无结果」的组合 */
|
||||
const filterFallbackHint = computed(() => {
|
||||
if (!templates.value.length) return ''
|
||||
const noIndustry = sortedUnique(templates.value.map((t) => t.industry)).length === 0
|
||||
const noProfession = sortedUnique(templates.value.map((t) => t.profession)).length === 0
|
||||
const noSub = sortedUnique(templates.value.map((t) => t.sub_category)).length === 0
|
||||
if (!noIndustry && !noProfession && !noSub) return ''
|
||||
return '提示:当前分类下部分模板未维护行业/职业/领域标签,下列为全库参考项;筛选过严时可能无匹配,可点「重置」。'
|
||||
})
|
||||
|
||||
watch(filteredTemplates, (list) => {
|
||||
if (!list.length) {
|
||||
selectedTemplateId.value = undefined
|
||||
return
|
||||
}
|
||||
if (selectedTemplateId.value == null || !list.some((x) => x.id === selectedTemplateId.value)) {
|
||||
selectedTemplateId.value = list[0].id
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedTemplateId, (id) => {
|
||||
if (!autoFillSample.value || id == null) return
|
||||
const t = templates.value.find((x) => x.id === id)
|
||||
if (t?.sample_input) inputText.value = t.sample_input
|
||||
})
|
||||
|
||||
async function loadMeta() {
|
||||
metaLoading.value = true
|
||||
try {
|
||||
const res = await fetchGenerateMeta()
|
||||
if (!res.success) {
|
||||
ElMessage.error('加载筛选数据失败')
|
||||
return
|
||||
}
|
||||
categories.value = res.categories?.length ? res.categories : ['通用']
|
||||
globalIndustries.value = res.industries ?? []
|
||||
globalProfessions.value = res.professions ?? []
|
||||
globalSubCategories.value = res.sub_categories ?? []
|
||||
if (!categories.value.includes(activeCategory.value)) {
|
||||
activeCategory.value = categories.value[0] ?? '通用'
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('无法连接后端,请确认 Flask 已启动')
|
||||
} finally {
|
||||
metaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncFiltersToLoadedTemplates() {
|
||||
if (industry.value !== 'all' && !availableIndustries.value.includes(industry.value)) {
|
||||
industry.value = 'all'
|
||||
}
|
||||
if (profession.value !== 'all' && !availableProfessions.value.includes(profession.value)) {
|
||||
profession.value = 'all'
|
||||
}
|
||||
if (subCategory.value !== 'all' && !availableSubCategories.value.includes(subCategory.value)) {
|
||||
subCategory.value = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplatesForCategory(cat: string) {
|
||||
templatesLoading.value = true
|
||||
result.value = null
|
||||
try {
|
||||
const res = await fetchTemplatesByCategory(cat)
|
||||
if (!res.success) {
|
||||
templates.value = []
|
||||
quickStart.value = []
|
||||
ElMessage.error('加载模板失败')
|
||||
return
|
||||
}
|
||||
templates.value = res.templates ?? []
|
||||
quickStart.value = res.quick_start ?? []
|
||||
syncFiltersToLoadedTemplates()
|
||||
const list = filteredTemplates.value
|
||||
selectedTemplateId.value = list.length ? list[0].id : undefined
|
||||
} catch {
|
||||
templates.value = []
|
||||
quickStart.value = []
|
||||
ElMessage.error('模板接口请求失败')
|
||||
} finally {
|
||||
templatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCategoryTab(name: string | number) {
|
||||
const cat = String(name)
|
||||
activeCategory.value = cat
|
||||
industry.value = 'all'
|
||||
profession.value = 'all'
|
||||
subCategory.value = 'all'
|
||||
void loadTemplatesForCategory(cat)
|
||||
}
|
||||
|
||||
function applyQuickStart(row: { id: number; name: string; sample_input: string }) {
|
||||
selectedTemplateId.value = row.id
|
||||
inputText.value = row.sample_input
|
||||
ElMessage.success(`已选择「${row.name}」,已填入示例需求`)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
industry.value = 'all'
|
||||
profession.value = 'all'
|
||||
subCategory.value = 'all'
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('请先描述需求')
|
||||
return
|
||||
}
|
||||
if (selectedTemplateId.value == null) {
|
||||
ElMessage.warning('请选择一个模板')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
result.value = null
|
||||
try {
|
||||
const res = await generatePrompt({
|
||||
input_text: text,
|
||||
template_id: selectedTemplateId.value,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
if (res.prompt) {
|
||||
result.value = res.prompt
|
||||
ElMessage.success('生成成功')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('请求失败,请检查网络或后端日志')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
const t = result.value?.generated_text
|
||||
if (!t) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(t)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择文本')
|
||||
}
|
||||
}
|
||||
|
||||
async function addToFavorites() {
|
||||
if (!result.value || selectedTemplateId.value == null) return
|
||||
const tpl = selectedTemplate.value
|
||||
favoriting.value = true
|
||||
try {
|
||||
const res = await quickAddFavorite({
|
||||
template_id: selectedTemplateId.value,
|
||||
original_text: result.value.input_text,
|
||||
generated_prompt: result.value.generated_text,
|
||||
system_prompt: tpl?.system_prompt ?? null,
|
||||
category: tpl?.category ?? null,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '收藏失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已加入收藏')
|
||||
} catch {
|
||||
ElMessage.error('收藏请求失败')
|
||||
} finally {
|
||||
favoriting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMeta()
|
||||
await loadTemplatesForCategory(activeCategory.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="metaLoading" class="generate-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">提示词生成(Vue)</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="page-desc">对接 <code>/api/generate/meta</code>、<code>/api/templates/<分类></code>、<code>/api/prompt/generate</code></p>
|
||||
|
||||
<el-tabs v-model="activeCategory" class="cat-tabs" @tab-change="onCategoryTab">
|
||||
<el-tab-pane v-for="c in categories" :key="c" :label="c" :name="c" />
|
||||
</el-tabs>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>快速开始</template>
|
||||
<el-empty v-if="!quickStart.length && !templatesLoading" description="当前分类暂无快速开始项" />
|
||||
<el-space v-else wrap>
|
||||
<el-button
|
||||
v-for="q in quickStart"
|
||||
:key="q.id"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="applyQuickStart(q)"
|
||||
>
|
||||
{{ q.name }}
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="section-head">
|
||||
<span>筛选</span>
|
||||
<el-button text type="primary" size="small" @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="filterFallbackHint" class="filter-fallback-hint">{{ filterFallbackHint }}</p>
|
||||
<el-form label-width="72px" class="filter-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="行业">
|
||||
<el-select v-model="industry" placeholder="全部" filterable style="width: 100%">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option v-for="i in availableIndustries" :key="i" :label="i" :value="i" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="职业">
|
||||
<el-select v-model="profession" placeholder="全部" filterable style="width: 100%">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option v-for="p in availableProfessions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="领域">
|
||||
<el-select v-model="subCategory" placeholder="全部" filterable style="width: 100%">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option v-for="s in availableSubCategories" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="templatesLoading" shadow="never" class="section">
|
||||
<template #header>选择模板</template>
|
||||
<div class="auto-fill">
|
||||
<el-checkbox v-model="autoFillSample">切换模板时自动填入示例需求</el-checkbox>
|
||||
</div>
|
||||
<el-empty v-if="!filteredTemplates.length" :description="emptyTemplateHint" />
|
||||
<el-radio-group v-else v-model="selectedTemplateId" class="tpl-group">
|
||||
<div v-for="t in filteredTemplates" :key="t.id" class="tpl-item">
|
||||
<el-radio :value="t.id" class="tpl-radio" border>
|
||||
<div class="tpl-body">
|
||||
<div class="tpl-name">{{ t.name }}</div>
|
||||
<div class="tpl-desc">{{ t.description || '暂无描述' }}</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>描述需求</template>
|
||||
<el-input
|
||||
v-model="inputText"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="请描述目标、背景、约束与期望输出…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" size="large" :loading="generating" @click="onSubmit">
|
||||
生成专业提示词
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result" shadow="never" class="section result-card">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>生成结果</span>
|
||||
<el-space>
|
||||
<el-button type="primary" plain size="small" :loading="favoriting" @click="addToFavorites">
|
||||
加入收藏
|
||||
</el-button>
|
||||
<el-button type="success" plain size="small" @click="copyResult">复制</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ result.generated_text }}</pre>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.generate-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cat-tabs {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-fallback-hint {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auto-fill {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tpl-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tpl-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tpl-radio {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-right: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tpl-body {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.tpl-name {
|
||||
font-weight: 600;
|
||||
color: $text-color;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tpl-desc {
|
||||
font-size: 0.875rem;
|
||||
color: $text-muted;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
287
vue-app/src/views/HistoryView.vue
Normal file
287
vue-app/src/views/HistoryView.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { fetchHistoryList, deleteHistoryItem } from '@/api/modules/history'
|
||||
import type { HistoryItem } from '@/api/types/history'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<HistoryItem[]>([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
per_page: 15,
|
||||
search: '',
|
||||
sort: 'created_at',
|
||||
})
|
||||
|
||||
const total = ref(0)
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const detail = ref<HistoryItem | null>(null)
|
||||
|
||||
function openDetail(row: HistoryItem) {
|
||||
detail.value = row
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function copyDetailPart(label: string, text: string | null | undefined) {
|
||||
const t = (text ?? '').trim()
|
||||
if (!t) {
|
||||
ElMessage.warning('无内容可复制')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(t)
|
||||
ElMessage.success(`已复制${label}`)
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择文本')
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchHistoryList({
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
search: query.search.trim() || undefined,
|
||||
sort: query.sort,
|
||||
})
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '加载失败')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
list.value = res.data.history
|
||||
total.value = res.data.pagination.total
|
||||
} catch {
|
||||
ElMessage.error('无法加载历史,请确认已登录且后端可用')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
query.page = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function onDelete(row: HistoryItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除历史记录 #${row.id}?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteHistoryItem(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('删除请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
|
||||
watch(
|
||||
() => [query.page, query.per_page, query.sort],
|
||||
() => load(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<el-page-header class="page-head" @back="$router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">优化历史</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">数据来自 <code>/api/history</code>;登录后与 Session 中的用户对齐。</p>
|
||||
|
||||
<el-card shadow="never" class="panel">
|
||||
<el-form :inline="true" class="toolbar" @submit.prevent="onSearch">
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="query.search"
|
||||
placeholder="原文 / 生成内容"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-select v-model="query.sort" style="width: 160px" @change="query.page = 1">
|
||||
<el-option label="最近优先" value="created_at" />
|
||||
<el-option label="评分优先" value="rating" />
|
||||
<el-option label="耗时升序" value="generation_time" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe empty-text="暂无记录" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column prop="template_name" label="模板" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="original_input" label="原始输入" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="generated_prompt" label="生成内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.created_at?.replace('T', ' ').slice(0, 19) || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openDetail(row)">详情</el-button>
|
||||
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.per_page"
|
||||
:total="total"
|
||||
:page-sizes="[10, 15, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="detail ? `历史 #${detail.id}` : '详情'"
|
||||
direction="rtl"
|
||||
size="min(520px, 92vw)"
|
||||
destroy-on-close
|
||||
@closed="detail = null"
|
||||
>
|
||||
<template v-if="detail">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">模板</div>
|
||||
<div class="detail-value">{{ detail.template_name || '—' }}</div>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-head">
|
||||
<div class="detail-label">原始输入</div>
|
||||
<el-button size="small" @click="copyDetailPart('原始输入', detail.original_input)">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
<pre class="detail-pre">{{ detail.original_input || '—' }}</pre>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-head">
|
||||
<div class="detail-label">生成内容</div>
|
||||
<el-button size="small" @click="copyDetailPart('生成内容', detail.generated_prompt)">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
<pre class="detail-pre">{{ detail.generated_prompt || '—' }}</pre>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span v-if="detail.generation_time != null">耗时 {{ detail.generation_time }} ms</span>
|
||||
<span v-if="detail.created_at"> · {{ detail.created_at.replace('T', ' ').slice(0, 19) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.history-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.9375rem;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.detail-pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: $gray-100;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
vue-app/src/views/HomeView.vue
Normal file
92
vue-app/src/views/HomeView.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const flaskOrigin = import.meta.env.VITE_FLASK_ORIGIN || ''
|
||||
const generateUrl = flaskOrigin ? `${flaskOrigin}/` : '/'
|
||||
|
||||
function openLegacyGenerate() {
|
||||
window.location.href = generateUrl
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<el-card shadow="never" class="hero">
|
||||
<h1>Vue 3 前端试验田</h1>
|
||||
<p class="lead">
|
||||
与现有 Flask 后端并行开发:开发时 Vite 将 <code>/api</code>、<code>/poetry</code>、<code>/static</code> 代理到后端;会话使用 Cookie(
|
||||
<code>withCredentials</code>
|
||||
)。建议开发使用 <strong>127.0.0.1:3000</strong>。经典版生成页也可从顶部 <strong>更多</strong> 菜单进入。
|
||||
</p>
|
||||
<el-space wrap>
|
||||
<el-button type="primary" @click="$router.push({ name: 'generate' })">Vue 提示词生成</el-button>
|
||||
<el-button @click="$router.push({ name: 'optimization' })">提示词优化</el-button>
|
||||
<el-button @click="$router.push({ name: 'resume-optimization' })">简历 / 求职信</el-button>
|
||||
<el-button @click="openLegacyGenerate">经典生成页(Flask)</el-button>
|
||||
<el-button @click="$router.push({ name: 'favorites' })">收藏列表</el-button>
|
||||
<el-button @click="$router.push({ name: 'history' })">优化历史</el-button>
|
||||
<el-button @click="$router.push({ name: 'meal-planning' })">饭菜规划</el-button>
|
||||
<el-button @click="$router.push({ name: 'poetry' })">古诗词</el-button>
|
||||
<el-button @click="$router.push({ name: 'android-tools' })">Android 工具</el-button>
|
||||
<el-button @click="$router.push({ name: 'profile' })">个人资料</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="status">
|
||||
<template #header>会话状态</template>
|
||||
<p v-if="!auth.ready">正在检测登录状态…</p>
|
||||
<template v-else>
|
||||
<p v-if="auth.loggedIn">
|
||||
已登录:<strong>{{ auth.nickname || auth.userId }}</strong>
|
||||
</p>
|
||||
<p v-else>未登录(收藏接口仍可能按访客策略返回数据,取决于后端)。</p>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.65;
|
||||
color: $text-secondary;
|
||||
font-size: 0.95rem;
|
||||
|
||||
code {
|
||||
font-size: 0.85em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
316
vue-app/src/views/MealPlanningView.vue
Normal file
316
vue-app/src/views/MealPlanningView.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { deleteMealPlan, fetchMealPlanList, generateMealPlan, saveMealPlan } from '@/api/modules/meal'
|
||||
import type { MealPlanRow } from '@/api/types/meal'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
region_type: '全国',
|
||||
diner_count: '2',
|
||||
meal_type: '午餐',
|
||||
hometown: '',
|
||||
preferences: '',
|
||||
dietary_restrictions: '',
|
||||
budget: '100',
|
||||
})
|
||||
|
||||
const regionOptions = ['全国', '川菜', '粤菜', '江浙菜', '北方菜', '西北风味', '家常菜']
|
||||
const mealTypes = ['早餐', '午餐', '晚餐', '夜宵', '聚餐']
|
||||
|
||||
const generating = ref(false)
|
||||
const saving = ref(false)
|
||||
const planText = ref('')
|
||||
const lastParams = ref<typeof form | null>(null)
|
||||
|
||||
const listLoading = ref(false)
|
||||
const plans = ref<MealPlanRow[]>([])
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await fetchMealPlanList({ page: page.value, per_page: perPage.value })
|
||||
if (!res.success || !res.data) {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
if (res.message) ElMessage.error(res.message)
|
||||
return
|
||||
}
|
||||
plans.value = res.data.plans
|
||||
total.value = res.data.pagination.total
|
||||
} catch {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
ElMessage.error('加载规划列表失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
const hometown = form.hometown.trim()
|
||||
if (!hometown) {
|
||||
ElMessage.warning('请填写用餐者家乡')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
planText.value = ''
|
||||
try {
|
||||
const res = await generateMealPlan({
|
||||
region_type: form.region_type,
|
||||
diner_count: form.diner_count,
|
||||
meal_type: form.meal_type,
|
||||
hometown,
|
||||
preferences: form.preferences.trim(),
|
||||
dietary_restrictions: form.dietary_restrictions.trim(),
|
||||
budget: form.budget,
|
||||
})
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
planText.value = res.data.meal_plan
|
||||
lastParams.value = { ...form, hometown }
|
||||
ElMessage.success('已生成,可保存到「我的规划」')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端已启动')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!planText.value.trim() || !lastParams.value) {
|
||||
ElMessage.warning('请先生成规划')
|
||||
return
|
||||
}
|
||||
const p = lastParams.value
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveMealPlan({
|
||||
meal_plan_content: planText.value,
|
||||
region_type: p.region_type,
|
||||
diner_count: p.diner_count,
|
||||
meal_type: p.meal_type,
|
||||
hometown: p.hometown,
|
||||
preferences: p.preferences,
|
||||
dietary_restrictions: p.dietary_restrictions,
|
||||
budget: p.budget,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已保存')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('保存请求失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPlan() {
|
||||
if (!planText.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(planText.value)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onPageSizeChange() {
|
||||
page.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
async function onDeleteRow(row: MealPlanRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除规划 #${row.id}?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteMealPlan(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadList())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meal-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">智能饭菜规划</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">对接 <code>/api/meal-planning/*</code>;保存与列表与登录 Session 对齐(未登录时后端可能落到默认用户)。</p>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>参数</template>
|
||||
<el-form label-width="100px" class="meal-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="地区风味">
|
||||
<el-select v-model="form.region_type" filterable style="width: 100%">
|
||||
<el-option v-for="r in regionOptions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="就餐人数">
|
||||
<el-input v-model="form.diner_count" placeholder="如 2、3~4" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="餐次">
|
||||
<el-select v-model="form.meal_type" style="width: 100%">
|
||||
<el-option v-for="m in mealTypes" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="预算(元)">
|
||||
<el-input v-model="form.budget" placeholder="如 100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="家乡" required>
|
||||
<el-input v-model="form.hometown" placeholder="必填,如 成都、杭州" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="喜好">
|
||||
<el-input v-model="form.preferences" type="textarea" :rows="2" placeholder="口味、必吃/不想吃等" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="禁忌">
|
||||
<el-input v-model="form.dietary_restrictions" type="textarea" :rows="2" placeholder="过敏、宗教饮食等" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="primary" :loading="generating" @click="onGenerate">生成饭菜清单</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="planText" shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>生成结果</span>
|
||||
<el-space>
|
||||
<el-button size="small" @click="copyPlan">复制</el-button>
|
||||
<el-button type="primary" size="small" :loading="saving" @click="onSave">保存到我的规划</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ planText }}</pre>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>我的规划</template>
|
||||
<el-table v-loading="listLoading" :data="plans" stripe empty-text="暂无保存记录" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column prop="meal_type" label="餐次" width="88" />
|
||||
<el-table-column prop="hometown" label="家乡" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="diner_count" label="人数" width="80" />
|
||||
<el-table-column prop="budget" label="预算" width="88" />
|
||||
<el-table-column prop="meal_plan_content" label="摘要" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="88" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="onDeleteRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="perPage"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadList"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.meal-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.meal-form {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
vue-app/src/views/OptimizationView.vue
Normal file
227
vue-app/src/views/OptimizationView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { generateQuickOptimization, generateSmartOptimization } from '@/api/modules/optimization'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeMode = ref('smart')
|
||||
|
||||
const smartInput = ref('')
|
||||
const smartLoading = ref(false)
|
||||
const intentDisplay = ref('')
|
||||
const smartResult = ref('')
|
||||
|
||||
const quickInput = ref('')
|
||||
const quickLoading = ref(false)
|
||||
const quickResult = ref('')
|
||||
|
||||
async function onSmartSubmit() {
|
||||
const t = smartInput.value.trim()
|
||||
if (!t) {
|
||||
ElMessage.warning('请描述您的需求或粘贴待优化的提示词')
|
||||
return
|
||||
}
|
||||
smartLoading.value = true
|
||||
intentDisplay.value = ''
|
||||
smartResult.value = ''
|
||||
try {
|
||||
const res = await generateSmartOptimization({ input_text: t })
|
||||
if (res.code !== 200 || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
intentDisplay.value = JSON.stringify(res.data.intent_analysis, null, 2)
|
||||
smartResult.value = res.data.generated_prompt
|
||||
ElMessage.success('优化完成(已写入历史)')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端可用')
|
||||
} finally {
|
||||
smartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onQuickSubmit() {
|
||||
const t = quickInput.value.trim()
|
||||
if (!t) {
|
||||
ElMessage.warning('请输入需求描述')
|
||||
return
|
||||
}
|
||||
quickLoading.value = true
|
||||
quickResult.value = ''
|
||||
try {
|
||||
const res = await generateQuickOptimization({ input_text: t })
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
quickResult.value = res.data.generated_text
|
||||
ElMessage.success('优化完成')
|
||||
} catch {
|
||||
ElMessage.error('请求失败')
|
||||
} finally {
|
||||
quickLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success(`已复制${label}`)
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="opt-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">提示词优化</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">
|
||||
<strong>智能优化</strong>对接
|
||||
<code>/api/smart-prompt-optimization/generate</code>
|
||||
(意图分析 + 专家级输出);<strong>快速优化</strong>对接
|
||||
<code>/api/prompt-optimization/generate</code>
|
||||
。
|
||||
</p>
|
||||
|
||||
<el-tabs v-model="activeMode" class="tabs">
|
||||
<el-tab-pane label="智能优化(两阶段)" name="smart">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="smartInput"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
maxlength="8000"
|
||||
show-word-limit
|
||||
placeholder="请描述需求,或粘贴一段希望改写成「专家级提示词」的原文…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="smartLoading" @click="onSmartSubmit">
|
||||
生成专家级提示词
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<template v-if="intentDisplay || smartResult">
|
||||
<el-card v-if="intentDisplay" shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>意图分析</span>
|
||||
<el-button size="small" @click="copyText(intentDisplay, '意图分析')">复制 JSON</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ intentDisplay }}</pre>
|
||||
</el-card>
|
||||
<el-card v-if="smartResult" shadow="never" class="section highlight">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>专家级提示词</span>
|
||||
<el-button type="primary" size="small" @click="copyText(smartResult, '提示词')">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ smartResult }}</pre>
|
||||
</el-card>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="快速优化(通用模板)" name="quick">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="quickInput"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
maxlength="4000"
|
||||
show-word-limit
|
||||
placeholder="输入简短需求描述,使用站内默认「通用提示词优化」模板生成结构化 Prompt…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="quickLoading" @click="onQuickSubmit">快速优化</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card v-if="quickResult" shadow="never" class="section highlight">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>优化结果</span>
|
||||
<el-button type="primary" size="small" @click="copyText(quickResult, '结果')">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ quickResult }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.opt-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.78em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&.highlight {
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
359
vue-app/src/views/PoetryView.vue
Normal file
359
vue-app/src/views/PoetryView.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { addPoetryFavorite, analyzePoetry, deletePoetryFavorite, fetchPoetryFavorites } from '@/api/modules/poetry'
|
||||
import type { PoetryFavoriteSummary } from '@/api/types/poetry'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref('analyze')
|
||||
|
||||
const form = reactive({
|
||||
poetry_title: '',
|
||||
author: '',
|
||||
dynasty: '',
|
||||
translation_style: '优美流畅',
|
||||
interpretation_depth: '深入浅出',
|
||||
purpose: '学习欣赏',
|
||||
target_audience: '一般读者',
|
||||
translation_quality: '高质量',
|
||||
annotation_detail: '详细准确',
|
||||
interpretation_level: '深入浅出',
|
||||
})
|
||||
|
||||
const analyzing = ref(false)
|
||||
const resultText = ref('')
|
||||
const poetryInfo = ref<{ title: string; author: string; dynasty: string } | null>(null)
|
||||
|
||||
const favoriting = ref(false)
|
||||
|
||||
const favLoading = ref(false)
|
||||
const favorites = ref<PoetryFavoriteSummary[]>([])
|
||||
const favPage = ref(1)
|
||||
const favPerPage = ref(10)
|
||||
const favTotal = ref(0)
|
||||
|
||||
async function loadFavorites() {
|
||||
favLoading.value = true
|
||||
try {
|
||||
const res = await fetchPoetryFavorites({
|
||||
page: favPage.value,
|
||||
per_page: favPerPage.value,
|
||||
})
|
||||
if (!res.success) {
|
||||
favorites.value = []
|
||||
favTotal.value = 0
|
||||
if (res.message) ElMessage.error(res.message)
|
||||
return
|
||||
}
|
||||
favorites.value = res.data ?? []
|
||||
favTotal.value = res.total ?? 0
|
||||
} catch {
|
||||
favorites.value = []
|
||||
favTotal.value = 0
|
||||
ElMessage.error('加载收藏失败')
|
||||
} finally {
|
||||
favLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'favorites') void loadFavorites()
|
||||
})
|
||||
|
||||
async function onAnalyze() {
|
||||
const title = form.poetry_title.trim()
|
||||
const author = form.author.trim()
|
||||
if (!title) {
|
||||
ElMessage.warning('请输入诗词标题')
|
||||
return
|
||||
}
|
||||
if (!author) {
|
||||
ElMessage.warning('请输入作者')
|
||||
return
|
||||
}
|
||||
analyzing.value = true
|
||||
resultText.value = ''
|
||||
poetryInfo.value = null
|
||||
try {
|
||||
const res = await analyzePoetry({
|
||||
poetry_title: title,
|
||||
author,
|
||||
dynasty: form.dynasty.trim(),
|
||||
translation_style: form.translation_style,
|
||||
interpretation_depth: form.interpretation_depth,
|
||||
purpose: form.purpose,
|
||||
target_audience: form.target_audience,
|
||||
translation_quality: form.translation_quality,
|
||||
annotation_detail: form.annotation_detail,
|
||||
interpretation_level: form.interpretation_level,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '解析失败')
|
||||
return
|
||||
}
|
||||
resultText.value = res.result ?? ''
|
||||
poetryInfo.value = res.poetry_info ?? { title, author, dynasty: form.dynasty.trim() }
|
||||
ElMessage.success('解析完成')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认 Vite 已代理 /poetry 且后端可用')
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onFavorite() {
|
||||
if (!resultText.value.trim() || !poetryInfo.value) {
|
||||
ElMessage.warning('请先完成解析')
|
||||
return
|
||||
}
|
||||
const info = poetryInfo.value
|
||||
favoriting.value = true
|
||||
try {
|
||||
const res = await addPoetryFavorite({
|
||||
poetry_title: info.title,
|
||||
author: info.author,
|
||||
dynasty: info.dynasty || form.dynasty.trim() || undefined,
|
||||
analysis_result: resultText.value,
|
||||
translation_style: form.translation_style,
|
||||
interpretation_depth: form.interpretation_depth,
|
||||
purpose: form.purpose,
|
||||
target_audience: form.target_audience,
|
||||
translation_quality: form.translation_quality,
|
||||
annotation_detail: form.annotation_detail,
|
||||
interpretation_level: form.interpretation_level,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '收藏失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已收藏')
|
||||
} catch {
|
||||
ElMessage.error('收藏请求失败')
|
||||
} finally {
|
||||
favoriting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
if (!resultText.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(resultText.value)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteFav(row: PoetryFavoriteSummary) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除收藏「${row.poetry_title}」?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deletePoetryFavorite(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadFavorites()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onFavPageSizeChange() {
|
||||
favPage.value = 1
|
||||
void loadFavorites()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (activeTab.value === 'favorites') void loadFavorites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poetry-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">古诗词解析</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">
|
||||
解析接口为 <code>/poetry/analyze</code>(非 <code>/api</code>),开发时已在 Vite 中配置代理。
|
||||
</p>
|
||||
|
||||
<el-tabs v-model="activeTab" class="tabs">
|
||||
<el-tab-pane label="解析" name="analyze">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-form label-width="112px" class="poetry-form">
|
||||
<el-form-item label="诗词标题" required>
|
||||
<el-input v-model="form.poetry_title" placeholder="如 静夜思" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="作者" required>
|
||||
<el-input v-model="form.author" placeholder="如 李白" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="朝代">
|
||||
<el-input v-model="form.dynasty" placeholder="如 唐(可选)" clearable />
|
||||
</el-form-item>
|
||||
<el-collapse class="adv">
|
||||
<el-collapse-item title="高级选项(译文风格、受众等)" name="adv">
|
||||
<el-form-item label="翻译风格">
|
||||
<el-input v-model="form.translation_style" />
|
||||
</el-form-item>
|
||||
<el-form-item label="解读深度">
|
||||
<el-input v-model="form.interpretation_depth" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用目的">
|
||||
<el-input v-model="form.purpose" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标读者">
|
||||
<el-input v-model="form.target_audience" />
|
||||
</el-form-item>
|
||||
<el-form-item label="译文质量">
|
||||
<el-input v-model="form.translation_quality" />
|
||||
</el-form-item>
|
||||
<el-form-item label="注释详细度">
|
||||
<el-input v-model="form.annotation_detail" />
|
||||
</el-form-item>
|
||||
<el-form-item label="解读层次">
|
||||
<el-input v-model="form.interpretation_level" />
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="analyzing" @click="onAnalyze">开始解析</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="resultText" shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>解析结果</span>
|
||||
<el-space>
|
||||
<el-button size="small" @click="copyResult">复制</el-button>
|
||||
<el-button type="primary" size="small" :loading="favoriting" @click="onFavorite">
|
||||
加入收藏
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ resultText }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="我的收藏" name="favorites">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-table
|
||||
v-loading="favLoading"
|
||||
:data="favorites"
|
||||
stripe
|
||||
empty-text="暂无收藏"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="poetry_title" label="标题" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="author" label="作者" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="dynasty" label="朝代" width="72" />
|
||||
<el-table-column prop="created_time" label="时间" width="168" />
|
||||
<el-table-column label="操作" width="88" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="onDeleteFav(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="favPage"
|
||||
v-model:page-size="favPerPage"
|
||||
:total="favTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadFavorites"
|
||||
@size-change="onFavPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.poetry-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.poetry-form {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.adv {
|
||||
margin-bottom: 1rem;
|
||||
border: none;
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
font-weight: 600;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
138
vue-app/src/views/ProfileView.vue
Normal file
138
vue-app/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { fetchProfile, updateProfile } from '@/api/modules/profile'
|
||||
import type { ProfileUser } from '@/api/types/profile'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
login_name: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
sex: 0,
|
||||
})
|
||||
|
||||
const createdTime = ref<string | null>(null)
|
||||
|
||||
function fillFromUser(u: ProfileUser) {
|
||||
form.login_name = u.login_name ?? ''
|
||||
form.nickname = u.nickname ?? ''
|
||||
form.email = u.email ?? ''
|
||||
form.mobile = u.mobile ?? ''
|
||||
form.sex = typeof u.sex === 'number' ? u.sex : 0
|
||||
createdTime.value = u.created_time ?? null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchProfile()
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '加载资料失败')
|
||||
return
|
||||
}
|
||||
fillFromUser(res.data)
|
||||
} catch {
|
||||
ElMessage.error('请先登录后再访问资料页')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await updateProfile({
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
mobile: form.mobile.trim() || undefined,
|
||||
sex: form.sex,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
await load()
|
||||
await auth.refresh()
|
||||
} catch {
|
||||
ElMessage.error('保存请求失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="profile-page">
|
||||
<el-page-header class="page-head" @back="$router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">个人资料</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="panel">
|
||||
<el-form label-width="100px" class="form" @submit.prevent="onSave">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.login_name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" required>
|
||||
<el-input v-model="form.nickname" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" type="email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机">
|
||||
<el-input v-model="form.mobile" />
|
||||
</el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-radio-group v-model="form.sex">
|
||||
<el-radio :value="0">未知</el-radio>
|
||||
<el-radio :value="1">男</el-radio>
|
||||
<el-radio :value="2">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="createdTime" label="注册时间">
|
||||
<span class="muted">{{ createdTime }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="saving">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.profile-page {
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 1rem;
|
||||
max-width: 560px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $text-muted;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
381
vue-app/src/views/ResumeOptimizationView.vue
Normal file
381
vue-app/src/views/ResumeOptimizationView.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
deleteResumeOptimization,
|
||||
fetchResumeOptimizationDetail,
|
||||
fetchResumeOptimizationList,
|
||||
generateResumeOptimization,
|
||||
saveResumeOptimization,
|
||||
} from '@/api/modules/resume'
|
||||
import type { ResumeDetailResponse, ResumeListItem, ResumeOptType } from '@/api/types/resume'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
opt_type: 'resume' as ResumeOptType,
|
||||
original_content: '',
|
||||
job_description: '',
|
||||
})
|
||||
|
||||
const generating = ref(false)
|
||||
const saving = ref(false)
|
||||
const optimized = ref('')
|
||||
|
||||
const listLoading = ref(false)
|
||||
const plans = ref<ResumeListItem[]>([])
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<NonNullable<ResumeDetailResponse['data']> | null>(null)
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await fetchResumeOptimizationList({ page: page.value, per_page: perPage.value })
|
||||
if (!res.success || !res.data) {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
if (res.message) ElMessage.error(res.message)
|
||||
return
|
||||
}
|
||||
plans.value = res.data.plans
|
||||
total.value = res.data.pagination.total
|
||||
} catch {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
ElMessage.error('加载列表失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageSizeChange() {
|
||||
page.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
const orig = form.original_content.trim()
|
||||
if (!orig) {
|
||||
ElMessage.warning('请填写简历内容或求职要点')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
optimized.value = ''
|
||||
try {
|
||||
const res = await generateResumeOptimization({
|
||||
opt_type: form.opt_type,
|
||||
original_content: orig,
|
||||
job_description: form.job_description.trim(),
|
||||
})
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
optimized.value = res.data.optimized
|
||||
ElMessage.success('生成完成')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端可用')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!optimized.value.trim()) {
|
||||
ElMessage.warning('请先生成优化结果')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveResumeOptimization({
|
||||
opt_type: form.opt_type,
|
||||
original_content: form.original_content.trim(),
|
||||
job_description: form.job_description.trim(),
|
||||
optimized_content: optimized.value,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已保存')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('保存请求失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOptimized() {
|
||||
if (!optimized.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(optimized.value)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function optTypeLabel(t: string) {
|
||||
return t === 'cover_letter' ? '求职信' : '简历'
|
||||
}
|
||||
|
||||
async function openDetail(row: ResumeListItem) {
|
||||
drawerVisible.value = true
|
||||
detail.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await fetchResumeOptimizationDetail(row.id)
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '加载详情失败')
|
||||
drawerVisible.value = false
|
||||
return
|
||||
}
|
||||
detail.value = res.data
|
||||
} catch {
|
||||
ElMessage.error('详情请求失败')
|
||||
drawerVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: ResumeListItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除记录 #${row.id}?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteResumeOptimization(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadList())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="resume-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">简历 / 求职信优化</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">对接 <code>/api/resume-optimization/*</code>;列表与保存与 Session 用户对齐。</p>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>生成</template>
|
||||
<el-form label-width="100px" class="resume-form">
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="form.opt_type">
|
||||
<el-radio value="resume">简历优化</el-radio>
|
||||
<el-radio value="cover_letter">求职信</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="原文" required>
|
||||
<el-input
|
||||
v-model="form.original_content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
maxlength="20000"
|
||||
show-word-limit
|
||||
placeholder="粘贴简历全文,或求职信所需要点…"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位描述">
|
||||
<el-input
|
||||
v-model="form.job_description"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="8000"
|
||||
show-word-limit
|
||||
placeholder="可选,用于针对性优化(岗位 JD)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="generating" @click="onGenerate">生成</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="optimized" shadow="never" class="section highlight">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>优化结果</span>
|
||||
<el-space>
|
||||
<el-button size="small" @click="copyOptimized">复制</el-button>
|
||||
<el-button type="primary" size="small" :loading="saving" @click="onSave">保存到历史</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ optimized }}</pre>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>历史记录</template>
|
||||
<el-table v-loading="listLoading" :data="plans" stripe empty-text="暂无记录" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column label="类型" width="96">
|
||||
<template #default="{ row }">
|
||||
{{ optTypeLabel(row.opt_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="original_content" label="原文摘要" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="132" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openDetail(row)">详情</el-button>
|
||||
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="perPage"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadList"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="drawerVisible" title="记录详情" direction="rtl" size="min(560px, 92vw)" destroy-on-close>
|
||||
<div v-loading="detailLoading" class="drawer-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">类型</div>
|
||||
<div>{{ optTypeLabel(detail.opt_type) }}</div>
|
||||
</div>
|
||||
<div v-if="detail.job_description" class="detail-block">
|
||||
<div class="detail-label">岗位描述</div>
|
||||
<pre class="detail-pre">{{ detail.job_description }}</pre>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">原文</div>
|
||||
<pre class="detail-pre">{{ detail.original_content }}</pre>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">优化后</div>
|
||||
<pre class="detail-pre">{{ detail.optimized_content }}</pre>
|
||||
</div>
|
||||
<div class="detail-meta">{{ detail.created_at?.replace('T', ' ').slice(0, 19) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.resume-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&.highlight {
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.resume-form {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: $gray-100;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
vue-app/src/vite-env.d.ts
vendored
Normal file
20
vue-app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
export {}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
requiresAuth?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_FLASK_ORIGIN: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user