feat(vue-app,flask): Vue 试验田全量对接与 Session 用户上下文统一

新增 vue-app(生成/收藏/历史/登录/优化/Android/饭菜/诗词/简历等),Flask 增加 user_context 并调整历史、生成、简历等路由;模板 base/generate 可访问性改进;补充部署说明与文档。

Made-with: Cursor
This commit is contained in:
renjianbo
2026-04-05 21:10:41 +08:00
parent 9a3f15f3e2
commit daa34582e9
77 changed files with 8999 additions and 528 deletions

16
vue-app/src/App.vue Normal file
View 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
View 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
View 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

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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 })
}
}

View File

@@ -0,0 +1,7 @@
export interface AndroidToolResponse {
success: boolean
message?: string
data?: {
result: string
}
}

View 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
}

View 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
}

View 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
}
}
}

View 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
}

View 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
}
}

View 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
}
}

View 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
}

View 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
}

View 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
}
}

View 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
}
}

View File

@@ -0,0 +1,9 @@
export interface CheckLoginResponse {
success: boolean
logged_in: boolean
user?: {
user_id: number
nickname?: string | null
login_name?: string | null
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View 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;

View 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;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 496 B

10
vue-app/src/auto-imports.d.ts vendored Normal file
View 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
View 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']
}
}

View 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>

View 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>

View 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
View 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
View 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

View 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 }
})

View 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>

View 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>

View 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>

View 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>

View 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/&lt;分类&gt;</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>

View 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>

View 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>

View 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、34" />
</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>

View 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>

View 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>

View 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>

View 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
View 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
}