feat(vue-app,flask): Vue 试验田全量对接与 Session 用户上下文统一
新增 vue-app(生成/收藏/历史/登录/优化/Android/饭菜/诗词/简历等),Flask 增加 user_context 并调整历史、生成、简历等路由;模板 base/generate 可访问性改进;补充部署说明与文档。 Made-with: Cursor
This commit is contained in:
237
vue-app/src/views/AndroidToolsView.vue
Normal file
237
vue-app/src/views/AndroidToolsView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
analyzeAndroidCrashLog,
|
||||
analyzeGradleConflict,
|
||||
generateCodeReview,
|
||||
generatePerfOptimize,
|
||||
generateTechReview,
|
||||
} from '@/api/modules/android'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const tab = ref('crash')
|
||||
|
||||
const inputs = reactive({
|
||||
crash: '',
|
||||
gradle: '',
|
||||
perf: '',
|
||||
tech: '',
|
||||
code: '',
|
||||
})
|
||||
|
||||
const results = reactive({
|
||||
crash: '',
|
||||
gradle: '',
|
||||
perf: '',
|
||||
tech: '',
|
||||
code: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function run(kind: keyof typeof inputs) {
|
||||
const text = inputs[kind].trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('请先输入内容')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
results[kind] = ''
|
||||
try {
|
||||
let res
|
||||
switch (kind) {
|
||||
case 'crash':
|
||||
res = await analyzeAndroidCrashLog(text)
|
||||
break
|
||||
case 'gradle':
|
||||
res = await analyzeGradleConflict(text)
|
||||
break
|
||||
case 'perf':
|
||||
res = await generatePerfOptimize(text)
|
||||
break
|
||||
case 'tech':
|
||||
res = await generateTechReview(text)
|
||||
break
|
||||
case 'code':
|
||||
res = await generateCodeReview(text)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '处理失败')
|
||||
return
|
||||
}
|
||||
results[kind] = res.data?.result ?? ''
|
||||
ElMessage.success('完成(已写入优化历史)')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端可用')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(kind: keyof typeof results) {
|
||||
const t = results[kind]
|
||||
if (!t) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(t)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="android-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">Android 工程师工具</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">对接 <code>/api/android/*</code>,结果会写入站内「优化历史」。</p>
|
||||
|
||||
<el-tabs v-model="tab" class="tabs">
|
||||
<el-tab-pane label="崩溃 / 日志解读" name="crash">
|
||||
<el-card v-loading="loading && tab === 'crash'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.crash"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="粘贴 Stack Trace、Logcat 异常片段…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('crash')">分析</el-button>
|
||||
<el-button v-if="results.crash" @click="copy('crash')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.crash" class="result-pre">{{ results.crash }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Gradle 依赖冲突" name="gradle">
|
||||
<el-card v-loading="loading && tab === 'gradle'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.gradle"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="粘贴 Gradle 报错、依赖冲突或 dependencies 输出片段…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('gradle')">分析</el-button>
|
||||
<el-button v-if="results.gradle" @click="copy('gradle')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.gradle" class="result-pre">{{ results.gradle }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="性能优化建议" name="perf">
|
||||
<el-card v-loading="loading && tab === 'perf'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.perf"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="描述启动慢、卡顿、内存、包体、耗电等场景…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('perf')">生成建议</el-button>
|
||||
<el-button v-if="results.perf" @click="copy('perf')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.perf" class="result-pre">{{ results.perf }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="技术方案评审" name="tech">
|
||||
<el-card v-loading="loading && tab === 'tech'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.tech"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="粘贴或描述技术方案、选型、改造设计…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('tech')">生成评审要点</el-button>
|
||||
<el-button v-if="results.tech" @click="copy('tech')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.tech" class="result-pre">{{ results.tech }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Code Review 清单" name="code">
|
||||
<el-card v-loading="loading && tab === 'code'" shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="inputs.code"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="说明模块类型(如网络层、自定义 View)或粘贴代码片段…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="loading" @click="run('code')">生成清单</el-button>
|
||||
<el-button v-if="results.code" @click="copy('code')">复制结果</el-button>
|
||||
</div>
|
||||
<pre v-if="results.code" class="result-pre">{{ results.code }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.android-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: $gray-100;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
91
vue-app/src/views/Auth/LoginView.vue
Normal file
91
vue-app/src/views/Auth/LoginView.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loginName = ref('')
|
||||
const loginPwd = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const redirectTo = computed(() => {
|
||||
const r = route.query.redirect
|
||||
if (typeof r === 'string' && r.startsWith('/') && !r.startsWith('/login')) return r
|
||||
return '/'
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
const name = loginName.value.trim()
|
||||
const pwd = loginPwd.value
|
||||
if (!name || !pwd) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(name, pwd)
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="auth-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="head">登录</div>
|
||||
</template>
|
||||
<el-form label-position="top" @submit.prevent="onSubmit">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="loginName" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="loginPwd" type="password" show-password autocomplete="current-password" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="foot">
|
||||
<router-link :to="{ name: 'register', query: route.query }">没有账号?注册</router-link>
|
||||
<router-link :to="{ name: 'home' }">返回首页</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.head {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
102
vue-app/src/views/Auth/RegisterView.vue
Normal file
102
vue-app/src/views/Auth/RegisterView.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loginName = ref('')
|
||||
const loginPwd = ref('')
|
||||
const loginPwd2 = ref('')
|
||||
const nickname = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
const name = loginName.value.trim()
|
||||
const pwd = loginPwd.value
|
||||
const pwd2 = loginPwd2.value
|
||||
const nick = nickname.value.trim()
|
||||
if (!name || !pwd || !nick) {
|
||||
ElMessage.warning('请填写用户名、密码与昵称')
|
||||
return
|
||||
}
|
||||
if (pwd !== pwd2) {
|
||||
ElMessage.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register({ login_name: name, login_pwd: pwd, nickname: nick })
|
||||
ElMessage.success('注册成功')
|
||||
const r = route.query.redirect
|
||||
const path =
|
||||
typeof r === 'string' && r.startsWith('/') && !r.startsWith('/register') ? r : '/'
|
||||
await router.replace(path)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="auth-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="head">注册</div>
|
||||
</template>
|
||||
<el-form label-position="top" @submit.prevent="onSubmit">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="nickname" autocomplete="nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="loginName" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="loginPwd" type="password" show-password autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="loginPwd2" type="password" show-password autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="foot">
|
||||
<router-link :to="{ name: 'login', query: route.query }">已有账号?登录</router-link>
|
||||
<router-link :to="{ name: 'home' }">返回首页</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.head {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
vue-app/src/views/FavoritesView.vue
Normal file
167
vue-app/src/views/FavoritesView.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { fetchFavorites, deleteFavorite } from '@/api/modules/favorite'
|
||||
import type { FavoriteItem } from '@/api/types/favorite'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<FavoriteItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
search: '',
|
||||
category: 'all',
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchFavorites({
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
search: query.search.trim() || undefined,
|
||||
category: query.category === 'all' ? undefined : query.category,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '加载失败')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
list.value = res.data ?? []
|
||||
total.value = res.total ?? 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('网络错误,请确认 Flask 已启动且 Vite 代理指向正确端口')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
query.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function onDelete(row: FavoriteItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除收藏 #${row.id} ?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteFavorite(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('删除请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
|
||||
watch(
|
||||
() => [query.page, query.per_page],
|
||||
() => load(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="favorites-page">
|
||||
<el-card shadow="never" class="panel">
|
||||
<template #header>
|
||||
<div class="panel-head">
|
||||
<span>我的收藏</span>
|
||||
<el-tag type="info" size="small">试点模块 · 对接 /api/favorites</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" class="filters" @submit.prevent="onSearch">
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="query.search"
|
||||
placeholder="原文 / 生成内容 / 备注"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="query.category" style="width: 140px" @change="onSearch">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="通用" value="通用" />
|
||||
<el-option label="考试" value="考试" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe empty-text="暂无收藏" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="category" label="分类" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="original_text" label="原始需求" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="generated_prompt" label="生成提示词" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="created_time" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="onDelete(row)"> 删除 </el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.per_page"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.favorites-page {
|
||||
.panel {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
504
vue-app/src/views/GenerateView.vue
Normal file
504
vue-app/src/views/GenerateView.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchGenerateMeta, fetchTemplatesByCategory, generatePrompt } from '@/api/modules/prompt'
|
||||
import { quickAddFavorite } from '@/api/modules/favorite'
|
||||
import type { PromptTemplateItem } from '@/api/types/template'
|
||||
|
||||
const metaLoading = ref(true)
|
||||
const templatesLoading = ref(false)
|
||||
const generating = ref(false)
|
||||
|
||||
const categories = ref<string[]>([])
|
||||
/** 全库维度(与 /api/generate/meta 一致);当当前分类模板未打某一维标签时,该维下拉回退到此列表,避免只剩「全部」 */
|
||||
const globalIndustries = ref<string[]>([])
|
||||
const globalProfessions = ref<string[]>([])
|
||||
const globalSubCategories = ref<string[]>([])
|
||||
|
||||
const activeCategory = ref('通用')
|
||||
const industry = ref('all')
|
||||
const profession = ref('all')
|
||||
const subCategory = ref('all')
|
||||
|
||||
const templates = ref<PromptTemplateItem[]>([])
|
||||
const quickStart = ref<{ id: number; name: string; sample_input: string }[]>([])
|
||||
const selectedTemplateId = ref<number | undefined>(undefined)
|
||||
|
||||
const inputText = ref('')
|
||||
const autoFillSample = ref(true)
|
||||
|
||||
const result = ref<{ id: number; input_text: string; generated_text: string } | null>(null)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const selectedTemplate = computed(
|
||||
() => templates.value.find((t) => t.id === selectedTemplateId.value) ?? null,
|
||||
)
|
||||
|
||||
const favoriting = ref(false)
|
||||
|
||||
/** 筛选项必须来自「当前分类」已加载模板;meta 全库维度会导致选到本分类不存在的值(如 通用 + API测试) */
|
||||
function sortedUnique(values: (string | null | undefined)[]) {
|
||||
return [...new Set(values.filter((x): x is string => Boolean(x && String(x).trim())))].sort((a, b) =>
|
||||
a.localeCompare(b, 'zh-CN'),
|
||||
)
|
||||
}
|
||||
|
||||
function optionsForDimension(fromTemplates: string[], global: string[]) {
|
||||
if (fromTemplates.length > 0) return fromTemplates
|
||||
if (templates.value.length > 0 && global.length > 0) return global
|
||||
return fromTemplates
|
||||
}
|
||||
|
||||
const availableIndustries = computed(() =>
|
||||
optionsForDimension(sortedUnique(templates.value.map((t) => t.industry)), globalIndustries.value),
|
||||
)
|
||||
const availableProfessions = computed(() =>
|
||||
optionsForDimension(sortedUnique(templates.value.map((t) => t.profession)), globalProfessions.value),
|
||||
)
|
||||
const availableSubCategories = computed(() =>
|
||||
optionsForDimension(sortedUnique(templates.value.map((t) => t.sub_category)), globalSubCategories.value),
|
||||
)
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return templates.value.filter((t) => {
|
||||
if (industry.value !== 'all' && t.industry !== industry.value) return false
|
||||
if (profession.value !== 'all' && t.profession !== profession.value) return false
|
||||
if (subCategory.value !== 'all' && t.sub_category !== subCategory.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const emptyTemplateHint = computed(() => {
|
||||
if (!templates.value.length) return '当前分类暂无模板'
|
||||
const narrowed =
|
||||
industry.value !== 'all' || profession.value !== 'all' || subCategory.value !== 'all'
|
||||
if (narrowed) {
|
||||
return '当前分类下没有符合筛选条件的模板,请重置筛选或换一项'
|
||||
}
|
||||
return '无匹配模板,请调整筛选或切换场景'
|
||||
})
|
||||
|
||||
/** 当前分类模板在某一维全部为空时,下拉使用全库列表,便于选到「本分类下无结果」的组合 */
|
||||
const filterFallbackHint = computed(() => {
|
||||
if (!templates.value.length) return ''
|
||||
const noIndustry = sortedUnique(templates.value.map((t) => t.industry)).length === 0
|
||||
const noProfession = sortedUnique(templates.value.map((t) => t.profession)).length === 0
|
||||
const noSub = sortedUnique(templates.value.map((t) => t.sub_category)).length === 0
|
||||
if (!noIndustry && !noProfession && !noSub) return ''
|
||||
return '提示:当前分类下部分模板未维护行业/职业/领域标签,下列为全库参考项;筛选过严时可能无匹配,可点「重置」。'
|
||||
})
|
||||
|
||||
watch(filteredTemplates, (list) => {
|
||||
if (!list.length) {
|
||||
selectedTemplateId.value = undefined
|
||||
return
|
||||
}
|
||||
if (selectedTemplateId.value == null || !list.some((x) => x.id === selectedTemplateId.value)) {
|
||||
selectedTemplateId.value = list[0].id
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedTemplateId, (id) => {
|
||||
if (!autoFillSample.value || id == null) return
|
||||
const t = templates.value.find((x) => x.id === id)
|
||||
if (t?.sample_input) inputText.value = t.sample_input
|
||||
})
|
||||
|
||||
async function loadMeta() {
|
||||
metaLoading.value = true
|
||||
try {
|
||||
const res = await fetchGenerateMeta()
|
||||
if (!res.success) {
|
||||
ElMessage.error('加载筛选数据失败')
|
||||
return
|
||||
}
|
||||
categories.value = res.categories?.length ? res.categories : ['通用']
|
||||
globalIndustries.value = res.industries ?? []
|
||||
globalProfessions.value = res.professions ?? []
|
||||
globalSubCategories.value = res.sub_categories ?? []
|
||||
if (!categories.value.includes(activeCategory.value)) {
|
||||
activeCategory.value = categories.value[0] ?? '通用'
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('无法连接后端,请确认 Flask 已启动')
|
||||
} finally {
|
||||
metaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncFiltersToLoadedTemplates() {
|
||||
if (industry.value !== 'all' && !availableIndustries.value.includes(industry.value)) {
|
||||
industry.value = 'all'
|
||||
}
|
||||
if (profession.value !== 'all' && !availableProfessions.value.includes(profession.value)) {
|
||||
profession.value = 'all'
|
||||
}
|
||||
if (subCategory.value !== 'all' && !availableSubCategories.value.includes(subCategory.value)) {
|
||||
subCategory.value = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplatesForCategory(cat: string) {
|
||||
templatesLoading.value = true
|
||||
result.value = null
|
||||
try {
|
||||
const res = await fetchTemplatesByCategory(cat)
|
||||
if (!res.success) {
|
||||
templates.value = []
|
||||
quickStart.value = []
|
||||
ElMessage.error('加载模板失败')
|
||||
return
|
||||
}
|
||||
templates.value = res.templates ?? []
|
||||
quickStart.value = res.quick_start ?? []
|
||||
syncFiltersToLoadedTemplates()
|
||||
const list = filteredTemplates.value
|
||||
selectedTemplateId.value = list.length ? list[0].id : undefined
|
||||
} catch {
|
||||
templates.value = []
|
||||
quickStart.value = []
|
||||
ElMessage.error('模板接口请求失败')
|
||||
} finally {
|
||||
templatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCategoryTab(name: string | number) {
|
||||
const cat = String(name)
|
||||
activeCategory.value = cat
|
||||
industry.value = 'all'
|
||||
profession.value = 'all'
|
||||
subCategory.value = 'all'
|
||||
void loadTemplatesForCategory(cat)
|
||||
}
|
||||
|
||||
function applyQuickStart(row: { id: number; name: string; sample_input: string }) {
|
||||
selectedTemplateId.value = row.id
|
||||
inputText.value = row.sample_input
|
||||
ElMessage.success(`已选择「${row.name}」,已填入示例需求`)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
industry.value = 'all'
|
||||
profession.value = 'all'
|
||||
subCategory.value = 'all'
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('请先描述需求')
|
||||
return
|
||||
}
|
||||
if (selectedTemplateId.value == null) {
|
||||
ElMessage.warning('请选择一个模板')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
result.value = null
|
||||
try {
|
||||
const res = await generatePrompt({
|
||||
input_text: text,
|
||||
template_id: selectedTemplateId.value,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
if (res.prompt) {
|
||||
result.value = res.prompt
|
||||
ElMessage.success('生成成功')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('请求失败,请检查网络或后端日志')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
const t = result.value?.generated_text
|
||||
if (!t) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(t)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择文本')
|
||||
}
|
||||
}
|
||||
|
||||
async function addToFavorites() {
|
||||
if (!result.value || selectedTemplateId.value == null) return
|
||||
const tpl = selectedTemplate.value
|
||||
favoriting.value = true
|
||||
try {
|
||||
const res = await quickAddFavorite({
|
||||
template_id: selectedTemplateId.value,
|
||||
original_text: result.value.input_text,
|
||||
generated_prompt: result.value.generated_text,
|
||||
system_prompt: tpl?.system_prompt ?? null,
|
||||
category: tpl?.category ?? null,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '收藏失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已加入收藏')
|
||||
} catch {
|
||||
ElMessage.error('收藏请求失败')
|
||||
} finally {
|
||||
favoriting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMeta()
|
||||
await loadTemplatesForCategory(activeCategory.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="metaLoading" class="generate-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">提示词生成(Vue)</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="page-desc">对接 <code>/api/generate/meta</code>、<code>/api/templates/<分类></code>、<code>/api/prompt/generate</code></p>
|
||||
|
||||
<el-tabs v-model="activeCategory" class="cat-tabs" @tab-change="onCategoryTab">
|
||||
<el-tab-pane v-for="c in categories" :key="c" :label="c" :name="c" />
|
||||
</el-tabs>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>快速开始</template>
|
||||
<el-empty v-if="!quickStart.length && !templatesLoading" description="当前分类暂无快速开始项" />
|
||||
<el-space v-else wrap>
|
||||
<el-button
|
||||
v-for="q in quickStart"
|
||||
:key="q.id"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="applyQuickStart(q)"
|
||||
>
|
||||
{{ q.name }}
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="section-head">
|
||||
<span>筛选</span>
|
||||
<el-button text type="primary" size="small" @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="filterFallbackHint" class="filter-fallback-hint">{{ filterFallbackHint }}</p>
|
||||
<el-form label-width="72px" class="filter-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="行业">
|
||||
<el-select v-model="industry" placeholder="全部" filterable style="width: 100%">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option v-for="i in availableIndustries" :key="i" :label="i" :value="i" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="职业">
|
||||
<el-select v-model="profession" placeholder="全部" filterable style="width: 100%">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option v-for="p in availableProfessions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="领域">
|
||||
<el-select v-model="subCategory" placeholder="全部" filterable style="width: 100%">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option v-for="s in availableSubCategories" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="templatesLoading" shadow="never" class="section">
|
||||
<template #header>选择模板</template>
|
||||
<div class="auto-fill">
|
||||
<el-checkbox v-model="autoFillSample">切换模板时自动填入示例需求</el-checkbox>
|
||||
</div>
|
||||
<el-empty v-if="!filteredTemplates.length" :description="emptyTemplateHint" />
|
||||
<el-radio-group v-else v-model="selectedTemplateId" class="tpl-group">
|
||||
<div v-for="t in filteredTemplates" :key="t.id" class="tpl-item">
|
||||
<el-radio :value="t.id" class="tpl-radio" border>
|
||||
<div class="tpl-body">
|
||||
<div class="tpl-name">{{ t.name }}</div>
|
||||
<div class="tpl-desc">{{ t.description || '暂无描述' }}</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>描述需求</template>
|
||||
<el-input
|
||||
v-model="inputText"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="请描述目标、背景、约束与期望输出…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" size="large" :loading="generating" @click="onSubmit">
|
||||
生成专业提示词
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result" shadow="never" class="section result-card">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>生成结果</span>
|
||||
<el-space>
|
||||
<el-button type="primary" plain size="small" :loading="favoriting" @click="addToFavorites">
|
||||
加入收藏
|
||||
</el-button>
|
||||
<el-button type="success" plain size="small" @click="copyResult">复制</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ result.generated_text }}</pre>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.generate-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cat-tabs {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-fallback-hint {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auto-fill {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tpl-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tpl-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tpl-radio {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-right: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tpl-body {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.tpl-name {
|
||||
font-weight: 600;
|
||||
color: $text-color;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tpl-desc {
|
||||
font-size: 0.875rem;
|
||||
color: $text-muted;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
287
vue-app/src/views/HistoryView.vue
Normal file
287
vue-app/src/views/HistoryView.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { fetchHistoryList, deleteHistoryItem } from '@/api/modules/history'
|
||||
import type { HistoryItem } from '@/api/types/history'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<HistoryItem[]>([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
per_page: 15,
|
||||
search: '',
|
||||
sort: 'created_at',
|
||||
})
|
||||
|
||||
const total = ref(0)
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const detail = ref<HistoryItem | null>(null)
|
||||
|
||||
function openDetail(row: HistoryItem) {
|
||||
detail.value = row
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function copyDetailPart(label: string, text: string | null | undefined) {
|
||||
const t = (text ?? '').trim()
|
||||
if (!t) {
|
||||
ElMessage.warning('无内容可复制')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(t)
|
||||
ElMessage.success(`已复制${label}`)
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择文本')
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchHistoryList({
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
search: query.search.trim() || undefined,
|
||||
sort: query.sort,
|
||||
})
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '加载失败')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
list.value = res.data.history
|
||||
total.value = res.data.pagination.total
|
||||
} catch {
|
||||
ElMessage.error('无法加载历史,请确认已登录且后端可用')
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
query.page = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function onDelete(row: HistoryItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除历史记录 #${row.id}?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteHistoryItem(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch {
|
||||
ElMessage.error('删除请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
|
||||
watch(
|
||||
() => [query.page, query.per_page, query.sort],
|
||||
() => load(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<el-page-header class="page-head" @back="$router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">优化历史</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">数据来自 <code>/api/history</code>;登录后与 Session 中的用户对齐。</p>
|
||||
|
||||
<el-card shadow="never" class="panel">
|
||||
<el-form :inline="true" class="toolbar" @submit.prevent="onSearch">
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="query.search"
|
||||
placeholder="原文 / 生成内容"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-select v-model="query.sort" style="width: 160px" @change="query.page = 1">
|
||||
<el-option label="最近优先" value="created_at" />
|
||||
<el-option label="评分优先" value="rating" />
|
||||
<el-option label="耗时升序" value="generation_time" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe empty-text="暂无记录" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column prop="template_name" label="模板" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="original_input" label="原始输入" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="generated_prompt" label="生成内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.created_at?.replace('T', ' ').slice(0, 19) || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openDetail(row)">详情</el-button>
|
||||
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.per_page"
|
||||
:total="total"
|
||||
:page-sizes="[10, 15, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="detail ? `历史 #${detail.id}` : '详情'"
|
||||
direction="rtl"
|
||||
size="min(520px, 92vw)"
|
||||
destroy-on-close
|
||||
@closed="detail = null"
|
||||
>
|
||||
<template v-if="detail">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">模板</div>
|
||||
<div class="detail-value">{{ detail.template_name || '—' }}</div>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-head">
|
||||
<div class="detail-label">原始输入</div>
|
||||
<el-button size="small" @click="copyDetailPart('原始输入', detail.original_input)">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
<pre class="detail-pre">{{ detail.original_input || '—' }}</pre>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-head">
|
||||
<div class="detail-label">生成内容</div>
|
||||
<el-button size="small" @click="copyDetailPart('生成内容', detail.generated_prompt)">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
<pre class="detail-pre">{{ detail.generated_prompt || '—' }}</pre>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span v-if="detail.generation_time != null">耗时 {{ detail.generation_time }} ms</span>
|
||||
<span v-if="detail.created_at"> · {{ detail.created_at.replace('T', ' ').slice(0, 19) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.history-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.9375rem;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.detail-pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: $gray-100;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
vue-app/src/views/HomeView.vue
Normal file
92
vue-app/src/views/HomeView.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const flaskOrigin = import.meta.env.VITE_FLASK_ORIGIN || ''
|
||||
const generateUrl = flaskOrigin ? `${flaskOrigin}/` : '/'
|
||||
|
||||
function openLegacyGenerate() {
|
||||
window.location.href = generateUrl
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<el-card shadow="never" class="hero">
|
||||
<h1>Vue 3 前端试验田</h1>
|
||||
<p class="lead">
|
||||
与现有 Flask 后端并行开发:开发时 Vite 将 <code>/api</code>、<code>/poetry</code>、<code>/static</code> 代理到后端;会话使用 Cookie(
|
||||
<code>withCredentials</code>
|
||||
)。建议开发使用 <strong>127.0.0.1:3000</strong>。经典版生成页也可从顶部 <strong>更多</strong> 菜单进入。
|
||||
</p>
|
||||
<el-space wrap>
|
||||
<el-button type="primary" @click="$router.push({ name: 'generate' })">Vue 提示词生成</el-button>
|
||||
<el-button @click="$router.push({ name: 'optimization' })">提示词优化</el-button>
|
||||
<el-button @click="$router.push({ name: 'resume-optimization' })">简历 / 求职信</el-button>
|
||||
<el-button @click="openLegacyGenerate">经典生成页(Flask)</el-button>
|
||||
<el-button @click="$router.push({ name: 'favorites' })">收藏列表</el-button>
|
||||
<el-button @click="$router.push({ name: 'history' })">优化历史</el-button>
|
||||
<el-button @click="$router.push({ name: 'meal-planning' })">饭菜规划</el-button>
|
||||
<el-button @click="$router.push({ name: 'poetry' })">古诗词</el-button>
|
||||
<el-button @click="$router.push({ name: 'android-tools' })">Android 工具</el-button>
|
||||
<el-button @click="$router.push({ name: 'profile' })">个人资料</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="status">
|
||||
<template #header>会话状态</template>
|
||||
<p v-if="!auth.ready">正在检测登录状态…</p>
|
||||
<template v-else>
|
||||
<p v-if="auth.loggedIn">
|
||||
已登录:<strong>{{ auth.nickname || auth.userId }}</strong>
|
||||
</p>
|
||||
<p v-else>未登录(收藏接口仍可能按访客策略返回数据,取决于后端)。</p>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.65;
|
||||
color: $text-secondary;
|
||||
font-size: 0.95rem;
|
||||
|
||||
code {
|
||||
font-size: 0.85em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
316
vue-app/src/views/MealPlanningView.vue
Normal file
316
vue-app/src/views/MealPlanningView.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { deleteMealPlan, fetchMealPlanList, generateMealPlan, saveMealPlan } from '@/api/modules/meal'
|
||||
import type { MealPlanRow } from '@/api/types/meal'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
region_type: '全国',
|
||||
diner_count: '2',
|
||||
meal_type: '午餐',
|
||||
hometown: '',
|
||||
preferences: '',
|
||||
dietary_restrictions: '',
|
||||
budget: '100',
|
||||
})
|
||||
|
||||
const regionOptions = ['全国', '川菜', '粤菜', '江浙菜', '北方菜', '西北风味', '家常菜']
|
||||
const mealTypes = ['早餐', '午餐', '晚餐', '夜宵', '聚餐']
|
||||
|
||||
const generating = ref(false)
|
||||
const saving = ref(false)
|
||||
const planText = ref('')
|
||||
const lastParams = ref<typeof form | null>(null)
|
||||
|
||||
const listLoading = ref(false)
|
||||
const plans = ref<MealPlanRow[]>([])
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await fetchMealPlanList({ page: page.value, per_page: perPage.value })
|
||||
if (!res.success || !res.data) {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
if (res.message) ElMessage.error(res.message)
|
||||
return
|
||||
}
|
||||
plans.value = res.data.plans
|
||||
total.value = res.data.pagination.total
|
||||
} catch {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
ElMessage.error('加载规划列表失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
const hometown = form.hometown.trim()
|
||||
if (!hometown) {
|
||||
ElMessage.warning('请填写用餐者家乡')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
planText.value = ''
|
||||
try {
|
||||
const res = await generateMealPlan({
|
||||
region_type: form.region_type,
|
||||
diner_count: form.diner_count,
|
||||
meal_type: form.meal_type,
|
||||
hometown,
|
||||
preferences: form.preferences.trim(),
|
||||
dietary_restrictions: form.dietary_restrictions.trim(),
|
||||
budget: form.budget,
|
||||
})
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
planText.value = res.data.meal_plan
|
||||
lastParams.value = { ...form, hometown }
|
||||
ElMessage.success('已生成,可保存到「我的规划」')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端已启动')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!planText.value.trim() || !lastParams.value) {
|
||||
ElMessage.warning('请先生成规划')
|
||||
return
|
||||
}
|
||||
const p = lastParams.value
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveMealPlan({
|
||||
meal_plan_content: planText.value,
|
||||
region_type: p.region_type,
|
||||
diner_count: p.diner_count,
|
||||
meal_type: p.meal_type,
|
||||
hometown: p.hometown,
|
||||
preferences: p.preferences,
|
||||
dietary_restrictions: p.dietary_restrictions,
|
||||
budget: p.budget,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已保存')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('保存请求失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPlan() {
|
||||
if (!planText.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(planText.value)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onPageSizeChange() {
|
||||
page.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
async function onDeleteRow(row: MealPlanRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除规划 #${row.id}?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteMealPlan(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadList())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meal-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">智能饭菜规划</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">对接 <code>/api/meal-planning/*</code>;保存与列表与登录 Session 对齐(未登录时后端可能落到默认用户)。</p>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>参数</template>
|
||||
<el-form label-width="100px" class="meal-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="地区风味">
|
||||
<el-select v-model="form.region_type" filterable style="width: 100%">
|
||||
<el-option v-for="r in regionOptions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="就餐人数">
|
||||
<el-input v-model="form.diner_count" placeholder="如 2、3~4" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="餐次">
|
||||
<el-select v-model="form.meal_type" style="width: 100%">
|
||||
<el-option v-for="m in mealTypes" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="预算(元)">
|
||||
<el-input v-model="form.budget" placeholder="如 100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="家乡" required>
|
||||
<el-input v-model="form.hometown" placeholder="必填,如 成都、杭州" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="喜好">
|
||||
<el-input v-model="form.preferences" type="textarea" :rows="2" placeholder="口味、必吃/不想吃等" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="禁忌">
|
||||
<el-input v-model="form.dietary_restrictions" type="textarea" :rows="2" placeholder="过敏、宗教饮食等" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="primary" :loading="generating" @click="onGenerate">生成饭菜清单</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="planText" shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>生成结果</span>
|
||||
<el-space>
|
||||
<el-button size="small" @click="copyPlan">复制</el-button>
|
||||
<el-button type="primary" size="small" :loading="saving" @click="onSave">保存到我的规划</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ planText }}</pre>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>我的规划</template>
|
||||
<el-table v-loading="listLoading" :data="plans" stripe empty-text="暂无保存记录" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column prop="meal_type" label="餐次" width="88" />
|
||||
<el-table-column prop="hometown" label="家乡" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="diner_count" label="人数" width="80" />
|
||||
<el-table-column prop="budget" label="预算" width="88" />
|
||||
<el-table-column prop="meal_plan_content" label="摘要" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="88" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="onDeleteRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="perPage"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadList"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.meal-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.meal-form {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
vue-app/src/views/OptimizationView.vue
Normal file
227
vue-app/src/views/OptimizationView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { generateQuickOptimization, generateSmartOptimization } from '@/api/modules/optimization'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeMode = ref('smart')
|
||||
|
||||
const smartInput = ref('')
|
||||
const smartLoading = ref(false)
|
||||
const intentDisplay = ref('')
|
||||
const smartResult = ref('')
|
||||
|
||||
const quickInput = ref('')
|
||||
const quickLoading = ref(false)
|
||||
const quickResult = ref('')
|
||||
|
||||
async function onSmartSubmit() {
|
||||
const t = smartInput.value.trim()
|
||||
if (!t) {
|
||||
ElMessage.warning('请描述您的需求或粘贴待优化的提示词')
|
||||
return
|
||||
}
|
||||
smartLoading.value = true
|
||||
intentDisplay.value = ''
|
||||
smartResult.value = ''
|
||||
try {
|
||||
const res = await generateSmartOptimization({ input_text: t })
|
||||
if (res.code !== 200 || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
intentDisplay.value = JSON.stringify(res.data.intent_analysis, null, 2)
|
||||
smartResult.value = res.data.generated_prompt
|
||||
ElMessage.success('优化完成(已写入历史)')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端可用')
|
||||
} finally {
|
||||
smartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onQuickSubmit() {
|
||||
const t = quickInput.value.trim()
|
||||
if (!t) {
|
||||
ElMessage.warning('请输入需求描述')
|
||||
return
|
||||
}
|
||||
quickLoading.value = true
|
||||
quickResult.value = ''
|
||||
try {
|
||||
const res = await generateQuickOptimization({ input_text: t })
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
quickResult.value = res.data.generated_text
|
||||
ElMessage.success('优化完成')
|
||||
} catch {
|
||||
ElMessage.error('请求失败')
|
||||
} finally {
|
||||
quickLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success(`已复制${label}`)
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="opt-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">提示词优化</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">
|
||||
<strong>智能优化</strong>对接
|
||||
<code>/api/smart-prompt-optimization/generate</code>
|
||||
(意图分析 + 专家级输出);<strong>快速优化</strong>对接
|
||||
<code>/api/prompt-optimization/generate</code>
|
||||
。
|
||||
</p>
|
||||
|
||||
<el-tabs v-model="activeMode" class="tabs">
|
||||
<el-tab-pane label="智能优化(两阶段)" name="smart">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="smartInput"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
maxlength="8000"
|
||||
show-word-limit
|
||||
placeholder="请描述需求,或粘贴一段希望改写成「专家级提示词」的原文…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="smartLoading" @click="onSmartSubmit">
|
||||
生成专家级提示词
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<template v-if="intentDisplay || smartResult">
|
||||
<el-card v-if="intentDisplay" shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>意图分析</span>
|
||||
<el-button size="small" @click="copyText(intentDisplay, '意图分析')">复制 JSON</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ intentDisplay }}</pre>
|
||||
</el-card>
|
||||
<el-card v-if="smartResult" shadow="never" class="section highlight">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>专家级提示词</span>
|
||||
<el-button type="primary" size="small" @click="copyText(smartResult, '提示词')">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ smartResult }}</pre>
|
||||
</el-card>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="快速优化(通用模板)" name="quick">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-input
|
||||
v-model="quickInput"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
maxlength="4000"
|
||||
show-word-limit
|
||||
placeholder="输入简短需求描述,使用站内默认「通用提示词优化」模板生成结构化 Prompt…"
|
||||
/>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="quickLoading" @click="onQuickSubmit">快速优化</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card v-if="quickResult" shadow="never" class="section highlight">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>优化结果</span>
|
||||
<el-button type="primary" size="small" @click="copyText(quickResult, '结果')">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ quickResult }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.opt-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.78em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&.highlight {
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
359
vue-app/src/views/PoetryView.vue
Normal file
359
vue-app/src/views/PoetryView.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { addPoetryFavorite, analyzePoetry, deletePoetryFavorite, fetchPoetryFavorites } from '@/api/modules/poetry'
|
||||
import type { PoetryFavoriteSummary } from '@/api/types/poetry'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref('analyze')
|
||||
|
||||
const form = reactive({
|
||||
poetry_title: '',
|
||||
author: '',
|
||||
dynasty: '',
|
||||
translation_style: '优美流畅',
|
||||
interpretation_depth: '深入浅出',
|
||||
purpose: '学习欣赏',
|
||||
target_audience: '一般读者',
|
||||
translation_quality: '高质量',
|
||||
annotation_detail: '详细准确',
|
||||
interpretation_level: '深入浅出',
|
||||
})
|
||||
|
||||
const analyzing = ref(false)
|
||||
const resultText = ref('')
|
||||
const poetryInfo = ref<{ title: string; author: string; dynasty: string } | null>(null)
|
||||
|
||||
const favoriting = ref(false)
|
||||
|
||||
const favLoading = ref(false)
|
||||
const favorites = ref<PoetryFavoriteSummary[]>([])
|
||||
const favPage = ref(1)
|
||||
const favPerPage = ref(10)
|
||||
const favTotal = ref(0)
|
||||
|
||||
async function loadFavorites() {
|
||||
favLoading.value = true
|
||||
try {
|
||||
const res = await fetchPoetryFavorites({
|
||||
page: favPage.value,
|
||||
per_page: favPerPage.value,
|
||||
})
|
||||
if (!res.success) {
|
||||
favorites.value = []
|
||||
favTotal.value = 0
|
||||
if (res.message) ElMessage.error(res.message)
|
||||
return
|
||||
}
|
||||
favorites.value = res.data ?? []
|
||||
favTotal.value = res.total ?? 0
|
||||
} catch {
|
||||
favorites.value = []
|
||||
favTotal.value = 0
|
||||
ElMessage.error('加载收藏失败')
|
||||
} finally {
|
||||
favLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'favorites') void loadFavorites()
|
||||
})
|
||||
|
||||
async function onAnalyze() {
|
||||
const title = form.poetry_title.trim()
|
||||
const author = form.author.trim()
|
||||
if (!title) {
|
||||
ElMessage.warning('请输入诗词标题')
|
||||
return
|
||||
}
|
||||
if (!author) {
|
||||
ElMessage.warning('请输入作者')
|
||||
return
|
||||
}
|
||||
analyzing.value = true
|
||||
resultText.value = ''
|
||||
poetryInfo.value = null
|
||||
try {
|
||||
const res = await analyzePoetry({
|
||||
poetry_title: title,
|
||||
author,
|
||||
dynasty: form.dynasty.trim(),
|
||||
translation_style: form.translation_style,
|
||||
interpretation_depth: form.interpretation_depth,
|
||||
purpose: form.purpose,
|
||||
target_audience: form.target_audience,
|
||||
translation_quality: form.translation_quality,
|
||||
annotation_detail: form.annotation_detail,
|
||||
interpretation_level: form.interpretation_level,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '解析失败')
|
||||
return
|
||||
}
|
||||
resultText.value = res.result ?? ''
|
||||
poetryInfo.value = res.poetry_info ?? { title, author, dynasty: form.dynasty.trim() }
|
||||
ElMessage.success('解析完成')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认 Vite 已代理 /poetry 且后端可用')
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onFavorite() {
|
||||
if (!resultText.value.trim() || !poetryInfo.value) {
|
||||
ElMessage.warning('请先完成解析')
|
||||
return
|
||||
}
|
||||
const info = poetryInfo.value
|
||||
favoriting.value = true
|
||||
try {
|
||||
const res = await addPoetryFavorite({
|
||||
poetry_title: info.title,
|
||||
author: info.author,
|
||||
dynasty: info.dynasty || form.dynasty.trim() || undefined,
|
||||
analysis_result: resultText.value,
|
||||
translation_style: form.translation_style,
|
||||
interpretation_depth: form.interpretation_depth,
|
||||
purpose: form.purpose,
|
||||
target_audience: form.target_audience,
|
||||
translation_quality: form.translation_quality,
|
||||
annotation_detail: form.annotation_detail,
|
||||
interpretation_level: form.interpretation_level,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '收藏失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已收藏')
|
||||
} catch {
|
||||
ElMessage.error('收藏请求失败')
|
||||
} finally {
|
||||
favoriting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
if (!resultText.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(resultText.value)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteFav(row: PoetryFavoriteSummary) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除收藏「${row.poetry_title}」?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deletePoetryFavorite(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadFavorites()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onFavPageSizeChange() {
|
||||
favPage.value = 1
|
||||
void loadFavorites()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (activeTab.value === 'favorites') void loadFavorites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poetry-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">古诗词解析</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">
|
||||
解析接口为 <code>/poetry/analyze</code>(非 <code>/api</code>),开发时已在 Vite 中配置代理。
|
||||
</p>
|
||||
|
||||
<el-tabs v-model="activeTab" class="tabs">
|
||||
<el-tab-pane label="解析" name="analyze">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-form label-width="112px" class="poetry-form">
|
||||
<el-form-item label="诗词标题" required>
|
||||
<el-input v-model="form.poetry_title" placeholder="如 静夜思" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="作者" required>
|
||||
<el-input v-model="form.author" placeholder="如 李白" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="朝代">
|
||||
<el-input v-model="form.dynasty" placeholder="如 唐(可选)" clearable />
|
||||
</el-form-item>
|
||||
<el-collapse class="adv">
|
||||
<el-collapse-item title="高级选项(译文风格、受众等)" name="adv">
|
||||
<el-form-item label="翻译风格">
|
||||
<el-input v-model="form.translation_style" />
|
||||
</el-form-item>
|
||||
<el-form-item label="解读深度">
|
||||
<el-input v-model="form.interpretation_depth" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用目的">
|
||||
<el-input v-model="form.purpose" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标读者">
|
||||
<el-input v-model="form.target_audience" />
|
||||
</el-form-item>
|
||||
<el-form-item label="译文质量">
|
||||
<el-input v-model="form.translation_quality" />
|
||||
</el-form-item>
|
||||
<el-form-item label="注释详细度">
|
||||
<el-input v-model="form.annotation_detail" />
|
||||
</el-form-item>
|
||||
<el-form-item label="解读层次">
|
||||
<el-input v-model="form.interpretation_level" />
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="analyzing" @click="onAnalyze">开始解析</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="resultText" shadow="never" class="section">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>解析结果</span>
|
||||
<el-space>
|
||||
<el-button size="small" @click="copyResult">复制</el-button>
|
||||
<el-button type="primary" size="small" :loading="favoriting" @click="onFavorite">
|
||||
加入收藏
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ resultText }}</pre>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="我的收藏" name="favorites">
|
||||
<el-card shadow="never" class="section">
|
||||
<el-table
|
||||
v-loading="favLoading"
|
||||
:data="favorites"
|
||||
stripe
|
||||
empty-text="暂无收藏"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="poetry_title" label="标题" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="author" label="作者" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="dynasty" label="朝代" width="72" />
|
||||
<el-table-column prop="created_time" label="时间" width="168" />
|
||||
<el-table-column label="操作" width="88" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="onDeleteFav(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="favPage"
|
||||
v-model:page-size="favPerPage"
|
||||
:total="favTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadFavorites"
|
||||
@size-change="onFavPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.poetry-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.poetry-form {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.adv {
|
||||
margin-bottom: 1rem;
|
||||
border: none;
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
font-weight: 600;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
138
vue-app/src/views/ProfileView.vue
Normal file
138
vue-app/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { fetchProfile, updateProfile } from '@/api/modules/profile'
|
||||
import type { ProfileUser } from '@/api/types/profile'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
login_name: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
sex: 0,
|
||||
})
|
||||
|
||||
const createdTime = ref<string | null>(null)
|
||||
|
||||
function fillFromUser(u: ProfileUser) {
|
||||
form.login_name = u.login_name ?? ''
|
||||
form.nickname = u.nickname ?? ''
|
||||
form.email = u.email ?? ''
|
||||
form.mobile = u.mobile ?? ''
|
||||
form.sex = typeof u.sex === 'number' ? u.sex : 0
|
||||
createdTime.value = u.created_time ?? null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchProfile()
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '加载资料失败')
|
||||
return
|
||||
}
|
||||
fillFromUser(res.data)
|
||||
} catch {
|
||||
ElMessage.error('请先登录后再访问资料页')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await updateProfile({
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
mobile: form.mobile.trim() || undefined,
|
||||
sex: form.sex,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
await load()
|
||||
await auth.refresh()
|
||||
} catch {
|
||||
ElMessage.error('保存请求失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="profile-page">
|
||||
<el-page-header class="page-head" @back="$router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">个人资料</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" class="panel">
|
||||
<el-form label-width="100px" class="form" @submit.prevent="onSave">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.login_name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" required>
|
||||
<el-input v-model="form.nickname" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" type="email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机">
|
||||
<el-input v-model="form.mobile" />
|
||||
</el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-radio-group v-model="form.sex">
|
||||
<el-radio :value="0">未知</el-radio>
|
||||
<el-radio :value="1">男</el-radio>
|
||||
<el-radio :value="2">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="createdTime" label="注册时间">
|
||||
<span class="muted">{{ createdTime }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="saving">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.profile-page {
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 1rem;
|
||||
max-width: 560px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $text-muted;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
381
vue-app/src/views/ResumeOptimizationView.vue
Normal file
381
vue-app/src/views/ResumeOptimizationView.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
deleteResumeOptimization,
|
||||
fetchResumeOptimizationDetail,
|
||||
fetchResumeOptimizationList,
|
||||
generateResumeOptimization,
|
||||
saveResumeOptimization,
|
||||
} from '@/api/modules/resume'
|
||||
import type { ResumeDetailResponse, ResumeListItem, ResumeOptType } from '@/api/types/resume'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
opt_type: 'resume' as ResumeOptType,
|
||||
original_content: '',
|
||||
job_description: '',
|
||||
})
|
||||
|
||||
const generating = ref(false)
|
||||
const saving = ref(false)
|
||||
const optimized = ref('')
|
||||
|
||||
const listLoading = ref(false)
|
||||
const plans = ref<ResumeListItem[]>([])
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<NonNullable<ResumeDetailResponse['data']> | null>(null)
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await fetchResumeOptimizationList({ page: page.value, per_page: perPage.value })
|
||||
if (!res.success || !res.data) {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
if (res.message) ElMessage.error(res.message)
|
||||
return
|
||||
}
|
||||
plans.value = res.data.plans
|
||||
total.value = res.data.pagination.total
|
||||
} catch {
|
||||
plans.value = []
|
||||
total.value = 0
|
||||
ElMessage.error('加载列表失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageSizeChange() {
|
||||
page.value = 1
|
||||
void loadList()
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
const orig = form.original_content.trim()
|
||||
if (!orig) {
|
||||
ElMessage.warning('请填写简历内容或求职要点')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
optimized.value = ''
|
||||
try {
|
||||
const res = await generateResumeOptimization({
|
||||
opt_type: form.opt_type,
|
||||
original_content: orig,
|
||||
job_description: form.job_description.trim(),
|
||||
})
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '生成失败')
|
||||
return
|
||||
}
|
||||
optimized.value = res.data.optimized
|
||||
ElMessage.success('生成完成')
|
||||
} catch {
|
||||
ElMessage.error('请求失败,请确认后端可用')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!optimized.value.trim()) {
|
||||
ElMessage.warning('请先生成优化结果')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveResumeOptimization({
|
||||
opt_type: form.opt_type,
|
||||
original_content: form.original_content.trim(),
|
||||
job_description: form.job_description.trim(),
|
||||
optimized_content: optimized.value,
|
||||
})
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(res.message || '已保存')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('保存请求失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOptimized() {
|
||||
if (!optimized.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(optimized.value)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function optTypeLabel(t: string) {
|
||||
return t === 'cover_letter' ? '求职信' : '简历'
|
||||
}
|
||||
|
||||
async function openDetail(row: ResumeListItem) {
|
||||
drawerVisible.value = true
|
||||
detail.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await fetchResumeOptimizationDetail(row.id)
|
||||
if (!res.success || !res.data) {
|
||||
ElMessage.error(res.message || '加载详情失败')
|
||||
drawerVisible.value = false
|
||||
return
|
||||
}
|
||||
detail.value = res.data
|
||||
} catch {
|
||||
ElMessage.error('详情请求失败')
|
||||
drawerVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: ResumeListItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除记录 #${row.id}?`, '确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await deleteResumeOptimization(row.id)
|
||||
if (!r.success) {
|
||||
ElMessage.error(r.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadList()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadList())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="resume-page">
|
||||
<el-page-header class="page-head" @back="router.push({ name: 'home' })">
|
||||
<template #content>
|
||||
<span class="page-title">简历 / 求职信优化</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
<p class="hint">对接 <code>/api/resume-optimization/*</code>;列表与保存与 Session 用户对齐。</p>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>生成</template>
|
||||
<el-form label-width="100px" class="resume-form">
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="form.opt_type">
|
||||
<el-radio value="resume">简历优化</el-radio>
|
||||
<el-radio value="cover_letter">求职信</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="原文" required>
|
||||
<el-input
|
||||
v-model="form.original_content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
maxlength="20000"
|
||||
show-word-limit
|
||||
placeholder="粘贴简历全文,或求职信所需要点…"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位描述">
|
||||
<el-input
|
||||
v-model="form.job_description"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="8000"
|
||||
show-word-limit
|
||||
placeholder="可选,用于针对性优化(岗位 JD)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="generating" @click="onGenerate">生成</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="optimized" shadow="never" class="section highlight">
|
||||
<template #header>
|
||||
<div class="result-head">
|
||||
<span>优化结果</span>
|
||||
<el-space>
|
||||
<el-button size="small" @click="copyOptimized">复制</el-button>
|
||||
<el-button type="primary" size="small" :loading="saving" @click="onSave">保存到历史</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="result-pre">{{ optimized }}</pre>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section">
|
||||
<template #header>历史记录</template>
|
||||
<el-table v-loading="listLoading" :data="plans" stripe empty-text="暂无记录" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column label="类型" width="96">
|
||||
<template #default="{ row }">
|
||||
{{ optTypeLabel(row.opt_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="original_content" label="原文摘要" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="132" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openDetail(row)">详情</el-button>
|
||||
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="perPage"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadList"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="drawerVisible" title="记录详情" direction="rtl" size="min(560px, 92vw)" destroy-on-close>
|
||||
<div v-loading="detailLoading" class="drawer-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">类型</div>
|
||||
<div>{{ optTypeLabel(detail.opt_type) }}</div>
|
||||
</div>
|
||||
<div v-if="detail.job_description" class="detail-block">
|
||||
<div class="detail-label">岗位描述</div>
|
||||
<pre class="detail-pre">{{ detail.job_description }}</pre>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">原文</div>
|
||||
<pre class="detail-pre">{{ detail.original_content }}</pre>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">优化后</div>
|
||||
<pre class="detail-pre">{{ detail.optimized_content }}</pre>
|
||||
</div>
|
||||
<div class="detail-meta">{{ detail.created_at?.replace('T', ' ').slice(0, 19) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.resume-page {
|
||||
.page-head {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: $text-secondary;
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
padding: 0.1em 0.35em;
|
||||
background: $gray-100;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&.highlight {
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.resume-form {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: $gray-100;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user