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

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>