fix: resolve 10 code review issues

High priority:
- Fix concurrent race condition for view_count/like_count (atomic update)
- Add route request ID tracking to prevent race conditions
- Filter get_joke by status=approved (no pending content leak)
- Add error feedback for like button

Performance:
- Optimize random joke query (avoid full table sort)
- Limit page_size max to 100 (DoS prevention)

Medium:
- Add localStorage quota error handling
- Handle empty AI response gracefully
- Fix generate content title extraction

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
This commit is contained in:
bwstudio
2026-06-02 20:35:08 +08:00
parent 0b43973236
commit ceed63fcb0
144 changed files with 191660 additions and 270 deletions
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>笑话大全</title>
</head>
<body>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

+49
View File
@@ -0,0 +1,49 @@
{
"hash": "14adaa8b",
"configHash": "c63d0069",
"lockfileHash": "c43dec38",
"browserHash": "ba0bb3cf",
"optimized": {
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "396a064a",
"needsInterop": false
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "90c27e2f",
"needsInterop": false
},
"pinia": {
"src": "../../pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "aeacc07a",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "89624437",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "5dd193a5",
"needsInterop": false
}
},
"chunks": {
"chunk-YFT6OQ5R": {
"file": "chunk-YFT6OQ5R.js"
},
"chunk-UC4PQXLF": {
"file": "chunk-UC4PQXLF.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"
}
}
}
+3108
View File
File diff suppressed because it is too large Load Diff
+7
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
export {
__commonJS,
__export,
__toESM
};
//# sourceMappingURL=chunk-G3PMV62Z.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}
+13045
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+162
View File
@@ -0,0 +1,162 @@
// node_modules/@vue/devtools-api/lib/esm/env.js
function getDevtoolsGlobalHook() {
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
}
function getTarget() {
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
}
var isProxyAvailable = typeof Proxy === "function";
// node_modules/@vue/devtools-api/lib/esm/const.js
var HOOK_SETUP = "devtools-plugin:setup";
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
// node_modules/@vue/devtools-api/lib/esm/time.js
var supported;
var perf;
function isPerformanceSupported() {
var _a;
if (supported !== void 0) {
return supported;
}
if (typeof window !== "undefined" && window.performance) {
supported = true;
perf = window.performance;
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
supported = true;
perf = globalThis.perf_hooks.performance;
} else {
supported = false;
}
return supported;
}
function now() {
return isPerformanceSupported() ? perf.now() : Date.now();
}
// node_modules/@vue/devtools-api/lib/esm/proxy.js
var ApiProxy = class {
constructor(plugin, hook) {
this.target = null;
this.targetQueue = [];
this.onQueue = [];
this.plugin = plugin;
this.hook = hook;
const defaultSettings = {};
if (plugin.settings) {
for (const id in plugin.settings) {
const item = plugin.settings[id];
defaultSettings[id] = item.defaultValue;
}
}
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
let currentSettings = Object.assign({}, defaultSettings);
try {
const raw = localStorage.getItem(localSettingsSaveId);
const data = JSON.parse(raw);
Object.assign(currentSettings, data);
} catch (e) {
}
this.fallbacks = {
getSettings() {
return currentSettings;
},
setSettings(value) {
try {
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
} catch (e) {
}
currentSettings = value;
},
now() {
return now();
}
};
if (hook) {
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
if (pluginId === this.plugin.id) {
this.fallbacks.setSettings(value);
}
});
}
this.proxiedOn = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target.on[prop];
} else {
return (...args) => {
this.onQueue.push({
method: prop,
args
});
};
}
}
});
this.proxiedTarget = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target[prop];
} else if (prop === "on") {
return this.proxiedOn;
} else if (Object.keys(this.fallbacks).includes(prop)) {
return (...args) => {
this.targetQueue.push({
method: prop,
args,
resolve: () => {
}
});
return this.fallbacks[prop](...args);
};
} else {
return (...args) => {
return new Promise((resolve) => {
this.targetQueue.push({
method: prop,
args,
resolve
});
});
};
}
}
});
}
async setRealTarget(target) {
this.target = target;
for (const item of this.onQueue) {
this.target.on[item.method](...item.args);
}
for (const item of this.targetQueue) {
item.resolve(await this.target[item.method](...item.args));
}
}
};
// node_modules/@vue/devtools-api/lib/esm/index.js
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
const descriptor = pluginDescriptor;
const target = getTarget();
const hook = getDevtoolsGlobalHook();
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
} else {
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
list.push({
pluginDescriptor: descriptor,
setupFn,
proxy
});
if (proxy) {
setupFn(proxy.proxiedTarget);
}
}
}
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-YFT6OQ5R.js.map
File diff suppressed because one or more lines are too long
+71932
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+1561
View File
File diff suppressed because it is too large Load Diff
+7
View File
File diff suppressed because one or more lines are too long
+2247
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+348
View File
@@ -0,0 +1,348 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-UC4PQXLF.js";
import "./chunk-G3PMV62Z.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#FF6B00"/>
<text x="16" y="24" font-size="22" text-anchor="middle" fill="white">😊</text>
</svg>

After

Width:  |  Height:  |  Size: 205 B

+263 -12
View File
@@ -1,21 +1,272 @@
<template>
<div id="app">
<AppHeader />
<main class="main-content">
<router-view />
</main>
<AppFooter />
<div id="app" class="app">
<!-- 顶部主导航 -->
<AppHeader
:theme="themeStore.theme"
@toggleTheme="themeStore.toggleTheme()"
@random="handleRandom"
/>
<!-- 内容区域: 左副导航 + 主内容 + 右信息栏 -->
<div class="content-wrapper">
<!-- 左侧副导航内容区内 -->
<AppSubNav
:types="allTypes"
:crowds="allCrowds"
:current-type-ids="filterTypeIds"
:current-crowd-ids="filterCrowdIds"
:show="showSubNav"
@select-type="handleSelectType"
@select-crowd="handleSelectCrowd"
@random="handleRandom"
/>
<!-- 主内容路由区 -->
<main class="main-content">
<router-view />
</main>
<!-- 右侧信息栏仅首页 -->
<AppRightbar
v-if="showRightBar"
:hot-jokes="hotJokes"
:types="allTypes"
:crowds="allCrowds"
/>
</div>
<!-- 底部 -->
<Footer />
</div>
</template>
<script setup>
import AppHeader from '@/components/Header.vue'
import AppFooter from '@/components/Footer.vue'
import { ref, provide, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import AppSubNav from '@/components/AppSubNav.vue'
import AppRightbar from '@/components/AppRightbar.vue'
import Footer from '@/components/Footer.vue'
import { getTypes, getCrowds } from '@/api/category'
import { getJokes } from '@/api/joke'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const route = useRoute()
const router = useRouter()
// 初始化主题
themeStore.initTheme()
// 共享分类数据
const allTypes = ref([])
const allCrowds = ref([])
// 当前筛选状态(支持多选)
const filterTypeIds = ref([])
const filterCrowdIds = ref([])
// 热门笑话(右侧栏)
const hotJokes = ref([])
// 右侧栏:仅首页显示
const showRightBar = computed(() => route.path === '/')
// 副导航:首页/分类/搜索显示,详情页显示推荐列表
const showSubNav = computed(() => !route.path.startsWith('/detail'))
// 函数必须先定义,才能在 provide 中使用
const handleRandom = async () => {
try {
const res = await getJokes({ page: 1, page_size: 50 })
if (res.items && res.items.length > 0) {
const rnd = res.items[Math.floor(Math.random() * res.items.length)]
router.push(`/detail/${rnd.id}`)
}
} catch (e) { /* ignore */ }
}
const handleSelectType = (typeId) => {
const ids = filterTypeIds.value
const idx = ids.indexOf(typeId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(typeId)
filterCrowdIds.value = []
const query = ids.length ? { type_ids: ids.join(',') } : {}
router.push({ path: '/', query })
}
const handleSelectCrowd = (crowdId) => {
const ids = filterCrowdIds.value
const idx = ids.indexOf(crowdId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(crowdId)
filterTypeIds.value = []
const query = ids.length ? { crowd_ids: ids.join(',') } : {}
router.push({ path: '/', query })
}
// provide 必须在函数定义之后
provide('filterTypeIds', filterTypeIds)
provide('filterCrowdIds', filterCrowdIds)
provide('openRandom', handleRandom)
provide('allTypes', allTypes)
provide('allCrowds', allCrowds)
// 同步 URL query 到 filter state
watch(() => route.query, (q) => {
filterTypeIds.value = q.type_ids ? String(q.type_ids).split(',').map(Number) : []
filterCrowdIds.value = q.crowd_ids ? String(q.crowd_ids).split(',').map(Number) : []
}, { immediate: true })
onMounted(async () => {
console.log('[APP] mounting, route:', route.path)
try {
allTypes.value = await getTypes()
allCrowds.value = await getCrowds()
console.log('[APP] categories loaded:', allTypes.value.length, allCrowds.value.length)
} catch (e) {
console.warn('[APP] Failed to load categories:', e)
}
try {
const res = await getJokes({ page: 1, page_size: 30 })
console.log('[APP] jokes loaded:', res.items?.length, 'total:', res.total)
const sorted = ((res.items || []).sort((a, b) => (b.view_count || 0) - (a.view_count || 0)))
hotJokes.value = sorted.slice(0, 5)
} catch (e) {
console.warn('[APP] Failed to load hot jokes:', e)
hotJokes.value = []
}
})
</script>
<style>
/* ============================================================
双主题 CSS 变量架构
============================================================ */
/* 浅色主题(默认) */
:root,
:root[data-theme="light"] {
--primary: #FF6B00;
--primary-light: #FF8C30;
--primary-dark: #E55A00;
--bg-page: #f0f2f5;
--bg-card: #ffffff;
--bg-subnav: #ffffff;
--bg-header: #ffffff;
--bg-sidebar: #1a1a2e;
--text-primary: #1f1f1f;
--text-secondary: #555555;
--text-muted: #999999;
--text-on-dark: #ffffff;
--border: #eeeeee;
--border-dark: #2a2a3e;
--shadow: 0 2px 12px rgba(0,0,0,0.06);
--shadow-hover: 0 8px 28px rgba(0,0,0,0.13);
--radius: 10px;
--radius-lg: 14px;
--tag-type-bg: #FFF0E6;
--tag-crowd-bg: #E3F2FD;
}
/* 深色主题 */
:root[data-theme="dark"] {
--primary: #FF8C30;
--primary-light: #FFA050;
--primary-dark: #FF6B00;
--bg-page: #0f0f14;
--bg-card: #1a1a24;
--bg-subnav: #1a1a24;
--bg-header: #141420;
--bg-sidebar: #0a0a14;
--text-primary: #e8e8ec;
--text-secondary: #a0a0a8;
--text-muted: #606068;
--text-on-dark: #ffffff;
--border: #2a2a3a;
--border-dark: #2a2a3e;
--shadow: 0 2px 12px rgba(0,0,0,0.25);
--shadow-hover: 0 8px 28px rgba(0,0,0,0.4);
--tag-type-bg: #3a2010;
--tag-crowd-bg: #0a2040;
}
/* ============================================================
全局基础样式
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fafafa; }
#app { min-height: 100vh; display: flex; flex-direction: column; }
.main-content { flex: 1; max-width: 1200px; margin: 0 auto; padding: 20px; width: 100%; }
</style>
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
background: var(--bg-page);
color: var(--text-primary);
line-height: 1.6;
transition: background 0.3s, color 0.3s;
}
#app { min-height: 100vh; }
/* ============================================================
整体布局
============================================================ */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 内容区域:副导航 + 主内容 + 右侧栏 */
.content-wrapper {
display: flex;
flex: 1;
max-width: 1440px;
margin: 0 auto;
width: 100%;
padding-top: 64px;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
padding: 24px 20px 40px;
}
/* ============================================================
辅助类
============================================================ */
.tag {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
transition: background 0.3s, color 0.3s;
}
.tag-type { background: var(--tag-type-bg, #FFF0E6); color: var(--primary); }
.tag-crowd { background: var(--tag-crowd-bg, #E8F4FD); color: #2196F3; }
/* ============================================================
Element Plus 覆盖
============================================================ */
.el-input__wrapper { border-radius: 8px !important; }
.el-button { border-radius: 8px !important; }
.el-pagination.is-background .el-pager li.is-active { background: var(--primary) !important; }
/* ============================================================
深色主题 Element Plus 额外覆盖
============================================================ */
[data-theme="dark"] .el-input__wrapper {
background: #252535 !important;
box-shadow: none !important;
border: 1px solid var(--border) !important;
}
[data-theme="dark"] .el-input__inner {
color: var(--text-primary) !important;
}
[data-theme="dark"] .el-pagination {
--el-pagination-bg-color: var(--bg-card);
--el-pagination-button-bg-color: var(--bg-card);
}
</style>
+2 -2
View File
@@ -1,4 +1,4 @@
import request from './request'
export const getTypes = () => request.get('/types')
export const getCrowds = () => request.get('/crowds')
export const getTypes = () => request.get('/categories/types')
export const getCrowds = () => request.get('/categories/crowds')
+3
View File
@@ -0,0 +1,3 @@
import request from '@/api/request'
export const submitFeedback = data => request.post('/feedback', data)
+3 -2
View File
@@ -1,6 +1,7 @@
import request from './request'
export const getJokes = params => request.get('/jokes', { params })
export const getJokes = params => request.get('/jokes/', { params })
export const getJoke = id => request.get(`/jokes/${id}`)
export const getRandomJoke = () => request.get('/jokes/random')
export const searchJokes = params => request.get('/search', { params })
export const likeJoke = id => request.post(`/jokes/${id}/like`)
export const searchJokes = params => request.get('/jokes/', { params })
+3
View File
@@ -0,0 +1,3 @@
import request from '@/api/request'
export const getLinks = () => request.get('/links')
+5 -1
View File
@@ -1,6 +1,10 @@
import axios from 'axios'
export default axios.create({
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.response.use(res => res.data)
export default request
+169
View File
@@ -0,0 +1,169 @@
<template>
<aside class="rightbar">
<!-- 热门笑话 -->
<div class="rightbar-section">
<div class="section-title">
<span class="title-icon">&#x1F525;</span>
热门笑话
</div>
<div class="hot-list" v-if="hotJokes.length">
<div
v-for="(joke, idx) in hotJokes"
:key="joke.id"
class="hot-item"
@click="$router.push(`/detail/${joke.id}`)"
>
<span class="hot-rank" :class="{ top: idx < 3 }">{{ idx + 1 }}</span>
<div class="hot-content">
<div class="hot-title">{{ joke.title }}</div>
<div class="hot-meta">&#x1F441; {{ joke.view_count || 0 }}</div>
</div>
</div>
</div>
<div v-else class="empty-hint">暂无数据</div>
</div>
<!-- 类型快捷 -->
<div class="rightbar-section">
<div class="section-title">
<span class="title-icon">&#x1F4CB;</span>
类型分类
</div>
<div class="quick-tags">
<span
v-for="t in (types || []).slice(0, 6)"
:key="t.id"
class="quick-tag type"
@click="$router.push({ path: '/', query: { type_id: t.id } })"
>{{ t.name }}</span>
</div>
</div>
<!-- 人群快捷 -->
<div class="rightbar-section">
<div class="section-title">
<span class="title-icon">&#x1F465;</span>
人群分类
</div>
<div class="quick-tags">
<span
v-for="c in (crowds || []).slice(0, 6)"
:key="c.id"
class="quick-tag crowd"
@click="$router.push({ path: '/', query: { crowd_id: c.id } })"
>{{ c.name }}</span>
</div>
</div>
<!-- 每日一笑 -->
<div class="rightbar-section daily-section">
<div class="section-title">
<span class="title-icon">&#x1F60A;</span>
每日一笑
</div>
<p class="daily-text">
生活太累笑一笑吧<br />
每天进步一点点<br />
快乐就多一点点
</p>
</div>
</aside>
</template>
<script setup>
defineProps({
hotJokes: { type: Array, default: () => [] },
types: { type: Array, default: () => [] },
crowds: { type: Array, default: () => [] }
})
</script>
<style scoped>
.rightbar {
width: 260px;
min-width: 260px;
padding: 24px 14px;
display: flex;
flex-direction: column;
gap: 18px;
flex-shrink: 0;
}
.rightbar-section {
background: var(--bg-card);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow);
transition: background 0.3s, box-shadow 0.3s;
}
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
transition: color 0.3s;
}
.title-icon { font-size: 15px; }
/* 热门列表 */
.hot-list { display: flex; flex-direction: column; gap: 12px; }
.hot-item {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
transition: opacity 0.2s;
}
.hot-item:hover { opacity: 0.7; }
.hot-rank {
min-width: 17px;
height: 17px;
line-height: 17px;
text-align: center;
background: var(--bg-page);
border-radius: 4px;
font-size: 10px;
color: var(--text-muted);
font-weight: 700;
flex-shrink: 0;
transition: background 0.3s;
}
.hot-rank.top { background: var(--tag-type-bg); color: var(--primary); }
.hot-title { font-size: 13px; color: var(--text-primary); line-height: 1.4; }
.hot-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; transition: color 0.3s; }
.empty-hint { font-size: 13px; color: var(--text-muted); text-align: center; padding: 12px 0; }
/* 快捷标签 */
.quick-tags { display: flex; flex-wrap: wrap; gap: 7px; }
.quick-tag {
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.quick-tag.type {
background: var(--tag-type-bg);
color: var(--primary);
}
.quick-tag.type:hover { background: var(--primary); color: white; }
.quick-tag.crowd {
background: var(--tag-crowd-bg);
color: #2196F3;
}
.quick-tag.crowd:hover { background: #2196F3; color: white; }
/* 每日一笑 */
.daily-section { background: var(--tag-type-bg); }
.daily-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
font-style: italic;
transition: color 0.3s;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<template>
<aside class="sidebar">
<!-- Logo 区域 -->
<div class="sidebar-logo" @click="$router.push('/')">
<span class="logo-icon">&#x1F60A;</span>
<span class="logo-text">笑话大全</span>
</div>
<!-- 主导航 -->
<nav class="sidebar-nav">
<div class="nav-label">导航</div>
<router-link to="/" class="nav-item" :class="{ active: $route.path === '/' }">
<span class="nav-icon">&#x1F3E0;</span>
<span>首页</span>
</router-link>
<router-link to="/category" class="nav-item" :class="{ active: $route.path === '/category' }">
<span class="nav-icon">&#x1F4CB;</span>
<span>分类</span>
</router-link>
<router-link to="/search" class="nav-item" :class="{ active: $route.path === '/search' }">
<span class="nav-icon">&#x1F50D;</span>
<span>搜索</span>
</router-link>
</nav>
<!-- 分类列表 -->
<div class="sidebar-categories">
<div class="nav-label">分类</div>
<div
v-for="type in types"
:key="type.id"
class="cat-item"
:class="{ active: currentType === type.id }"
@click="$emit('selectType', type.id)"
>
{{ type.name }}
</div>
</div>
<!-- 底部随机按钮 -->
<div class="sidebar-footer">
<button class="random-btn" @click="$emit('random')">
<span>&#x1F3B2;</span> 随机看看
</button>
</div>
</aside>
</template>
<script setup>
defineProps({
types: { type: Array, default: () => [] },
currentType: { type: Number, default: null }
})
defineEmits(['selectType', 'random'])
</script>
<style scoped>
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar::-webkit-scrollbar { width: 0; }
/* Logo */
.sidebar-logo {
padding: 28px 20px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.logo-icon { font-size: 28px; }
.logo-text {
font-size: 20px;
font-weight: bold;
color: white;
letter-spacing: 2px;
}
/* 导航 */
.sidebar-nav { padding: 16px 12px 8px; }
.nav-label {
color: rgba(255,255,255,0.35);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
padding: 0 8px;
margin-bottom: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
color: rgba(255,255,255,0.7);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
margin-bottom: 2px;
}
.nav-item:hover {
background: rgba(255,255,255,0.08);
color: white;
}
.nav-item.active {
background: rgba(255,107,0,0.25);
color: #FF8C30;
font-weight: 600;
}
.nav-icon { font-size: 16px; }
/* 分类 */
.sidebar-categories {
padding: 8px 12px;
flex: 1;
}
.cat-item {
padding: 8px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.6);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 2px;
}
.cat-item:hover {
background: rgba(255,255,255,0.06);
color: white;
}
.cat-item.active {
background: rgba(255,107,0,0.2);
color: var(--primary-light, #FF8C30);
}
/* 底部随机 */
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.random-btn {
width: 100%;
padding: 10px 16px;
background: var(--primary, #FF6B00);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
}
.random-btn:hover { background: var(--primary-light, #FF8C30); transform: translateY(-1px); }
</style>
+158
View File
@@ -0,0 +1,158 @@
<template>
<aside class="sub-nav" v-if="show">
<!-- Logo mini -->
<div class="sub-nav-brand" @click="$router.push('/')">
<span class="brand-icon">&#x1F60A;</span>
<span class="brand-text">笑话大全</span>
</div>
<!-- 分类筛选 -->
<div class="filter-section">
<div class="section-label">
<span>&#x1F4CB;</span> 类型
</div>
<div
v-for="type in types"
:key="type.id"
class="filter-item"
:class="{ active: currentTypeIds.includes(type.id) }"
@click="$emit('selectType', type.id)"
>
{{ type.name }}
</div>
</div>
<div class="section-divider"></div>
<div class="filter-section">
<div class="section-label">
<span>&#x1F465;</span> 人群
</div>
<div
v-for="crowd in crowds"
:key="crowd.id"
class="filter-item"
:class="{ active: currentCrowdIds.includes(crowd.id) }"
@click="$emit('selectCrowd', crowd.id)"
>
{{ crowd.name }}
</div>
</div>
<!-- 底部随机 -->
<div class="sub-nav-footer">
<button class="random-btn" @click="$emit('random')">
<span>&#x1F3B2;</span> 随机看看
</button>
</div>
</aside>
</template>
<script setup>
defineProps({
types: { type: Array, default: () => [] },
crowds: { type: Array, default: () => [] },
currentTypeIds: { type: Array, default: () => [] },
currentCrowdIds: { type: Array, default: () => [] },
show: { type: Boolean, default: true }
})
defineEmits(['selectType', 'selectCrowd', 'random'])
</script>
<style scoped>
.sub-nav {
width: 220px;
min-width: 220px;
height: 100%;
background: var(--bg-subnav);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px 12px;
overflow-y: auto;
transition: background 0.3s, border-color 0.3s;
flex-shrink: 0;
}
.sub-nav::-webkit-scrollbar { width: 0; }
/* Brand */
.sub-nav-brand {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
cursor: pointer;
}
.brand-icon { font-size: 22px; }
.brand-text {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 1px;
}
/* Filter sections */
.filter-section { padding: 4px 0; }
.section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1.2px;
padding: 6px 10px 8px;
}
.section-divider { height: 1px; background: var(--border); margin: 8px 0; }
.filter-item {
padding: 9px 12px;
border-radius: 8px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
}
.filter-item:hover {
background: var(--primary);
color: white;
transform: translateX(3px);
}
.filter-item.active {
background: var(--primary);
color: white;
font-weight: 600;
}
/* Footer */
.sub-nav-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.random-btn {
width: 100%;
padding: 10px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
}
.random-btn:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
</style>
+46 -7
View File
@@ -1,15 +1,54 @@
<template>
<footer class="footer">
<p>笑话大全 - 让快乐传递</p>
<footer class="app-footer">
<div class="footer-inner">
<nav class="footer-nav">
<router-link to="/" class="footer-link">首页</router-link>
<router-link to="/links" class="footer-link">友情链接</router-link>
<router-link to="/feedback" class="footer-link">意见建议</router-link>
<router-link to="/contact" class="footer-link">联系我们</router-link>
</nav>
<p class="footer-copyright">笑话大全 &mdash; 让快乐传递每一刻</p>
</div>
</footer>
</template>
<style scoped>
.footer {
background: #333;
color: white;
.app-footer {
background: var(--bg-card);
border-top: 1px solid var(--border);
padding: 24px 20px;
text-align: center;
padding: 20px;
margin-top: auto;
transition: background 0.3s, border-color 0.3s;
}
</style>
.footer-inner {
max-width: 1440px;
margin: 0 auto;
}
.footer-nav {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.footer-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
.footer-link:hover {
color: var(--primary);
}
.footer-copyright {
color: var(--text-muted);
font-size: 13px;
margin: 0;
}
</style>
+4 -1
View File
@@ -11,7 +11,10 @@ export const useJokeStore = defineStore('joke', {
async fetchJokes(params) {
this.loading = true
try {
const res = await getJokes(params)
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v != null && v !== '')
)
const res = await getJokes(cleanParams)
this.jokes = res.items
this.total = res.total
} finally {
+17
View File
@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
export const useThemeStore = defineStore('theme', {
state: () => ({
theme: localStorage.getItem('joke-theme') || 'light'
}),
actions: {
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light'
localStorage.setItem('joke-theme', this.theme)
document.documentElement.setAttribute('data-theme', this.theme)
},
initTheme() {
document.documentElement.setAttribute('data-theme', this.theme)
}
}
})
+67 -41
View File
@@ -1,72 +1,98 @@
<template>
<div class="category-page">
<h2>分类浏览</h2>
<!-- 分类标签展示页 -->
<div class="category-header">
<div class="cat-title">笑话分类</div>
</div>
<!-- 按类型 -->
<div class="category-section">
<h3>按类型</h3>
<div class="category-list">
<div
<div class="section-title">&#x1F4CB; 按类型</div>
<div class="category-pills">
<span
v-for="type in types"
:key="type.id"
class="category-card"
@click="selectFilter('type_id', type.id)"
>
<span class="cat-name">{{ type.name }}</span>
</div>
class="pill type-pill"
@click="router.push({ path: '/', query: { type_ids: String(type.id) } })"
>{{ type.name }}</span>
</div>
</div>
<!-- 按人群 -->
<div class="category-section">
<h3>按人群</h3>
<div class="category-list">
<div
v-for="crowd in crowds"
:key="crowd.id"
class="category-card"
@click="selectFilter('crowd_id', crowd.id)"
>
<span class="cat-name">{{ crowd.name }}</span>
</div>
<div class="section-title">&#x1F465; 按人群</div>
<div class="category-pills">
<span
v-for="c in crowds"
:key="c.id"
class="pill crowd-pill"
@click="router.push({ path: '/', query: { crowd_ids: String(c.id) } })"
>{{ c.name }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { getTypes, getCrowds } from '@/api/category'
const router = useRouter()
const types = ref([])
const crowds = ref([])
const selectFilter = (key, value) => {
router.push({ path: '/', query: { [key]: value } })
window.location.reload()
}
const allTypes = inject('allTypes')
const allCrowds = inject('allCrowds')
onMounted(async () => {
types.value = await getTypes()
crowds.value = await getCrowds()
types.value = allTypes.value?.length ? allTypes.value : await getTypes()
crowds.value = allCrowds.value?.length ? allCrowds.value : await getCrowds()
})
</script>
<style scoped>
.category-page h2 { margin-bottom: 20px; }
.category-section { margin-bottom: 30px; }
.category-section h3 { margin-bottom: 15px; color: #666; }
.category-list { display: flex; flex-wrap: wrap; gap: 12px; }
.category-card {
padding: 12px 24px;
background: white;
border-radius: 25px;
.category-page { display: flex; flex-direction: column; gap: 24px; }
.category-header { margin-bottom: 4px; }
.cat-title {
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
}
.category-section {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px 24px;
box-shadow: var(--shadow);
transition: background 0.3s;
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.category-pills { display: flex; flex-wrap: wrap; gap: 10px; }
.pill {
padding: 7px 18px;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: all 0.2s;
}
.category-card:hover {
background: #FF9500;
color: white;
transform: scale(1.05);
.type-pill {
background: #FFF0E6;
color: var(--primary);
}
.cat-name { font-size: 15px; }
.type-pill:hover { background: var(--primary); color: white; }
.crowd-pill {
background: #E3F2FD;
color: #2196F3;
}
.crowd-pill:hover { background: #2196F3; color: white; }
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="contact-page">
<h1 class="page-title">联系我们</h1>
<p class="page-subtitle">期待与您的沟通</p>
<div class="contact-cards">
<!-- 邮箱 -->
<div class="contact-card">
<div class="contact-icon">&#x1F4E7;</div>
<div class="contact-label">电子邮箱</div>
<a class="contact-value" href="mailto:contact@xiaohua.com">contact@xiaohua.com</a>
</div>
<!-- 社交媒体 -->
<div class="contact-card">
<div class="contact-icon">&#x1F30D;</div>
<div class="contact-label">社交媒体</div>
<p class="contact-value">微信公众号笑话大全</p>
<p class="contact-value">微博@笑话大全</p>
</div>
<!-- 反馈通道 -->
<div class="contact-card">
<div class="contact-icon">&#x1F4AC;</div>
<div class="contact-label">反馈通道</div>
<p class="contact-value">
您也可以前往
<router-link to="/feedback" class="inline-link">意见建议</router-link>
页面直接提交反馈
</p>
</div>
</div>
</div>
</template>
<style scoped>
.contact-page {
max-width: 800px;
margin: 0 auto;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 28px;
}
.contact-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.contact-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow);
text-align: center;
transition: box-shadow 0.25s, transform 0.25s;
}
.contact-card:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
.contact-icon {
font-size: 36px;
margin-bottom: 12px;
}
.contact-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
}
.contact-value {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.inline-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.inline-link:hover {
text-decoration: underline;
}
</style>
+219 -35
View File
@@ -1,71 +1,255 @@
<template>
<div class="detail-page" v-if="joke">
<div class="joke-detail">
<div class="tags">
<span class="tag type-tag" v-if="joke.type_name">{{ joke.type_name }}</span>
<span class="tag crowd-tag" v-if="joke.crowd_name">{{ joke.crowd_name }}</span>
<div class="joke-detail-card">
<!-- 标签 -->
<div class="detail-tags">
<span class="tag tag-type" v-for="(name, i) in (joke.type_names||[])" :key="'t'+i">{{ name }}</span>
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div>
<h1 class="title">{{ joke.title }}</h1>
<div class="content">{{ joke.content }}</div>
<div class="actions">
<button class="action-btn" @click="handleRandom">再看一条</button>
<!-- 标题 -->
<h1 class="detail-title">{{ joke.title }}</h1>
<!-- 内容 -->
<div class="detail-content">{{ joke.content }}</div>
<!-- 操作栏 -->
<div class="detail-actions">
<button class="action-primary" @click="handleNext">
<span>&#x1F3B2;</span> 再看一条
</button>
<button class="action-secondary" @click="$router.push('/')">
<span>&#x1F3E0;</span> 返回首页
</button>
</div>
<div class="meta">
浏览 {{ joke.view_count }} | {{ joke.like_count }}
<!-- 统计 -->
<div class="detail-meta">
<span>浏览 {{ joke.view_count || 0 }}</span>
<span class="sep">|</span>
<button class="like-btn" :class="{ 'liked': liked }" @click.stop="handleLike">
<span class="heart">&#x2764;</span>
<span>点赞 {{ joke.like_count || 0 }}</span>
</button>
<span class="sep">|</span>
<span>{{ formatDate(joke.created_at) }}</span>
</div>
</div>
</div>
<div class="detail-loading" v-else>
<div class="spinner"></div>
<span>加载中...</span>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJoke } from '@/api/joke'
import { getJoke, getJokes, likeJoke } from '@/api/joke'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const joke = ref(null)
const liked = ref(false)
const loading = ref(false)
// 请求 ID 追踪,防止竞态
let currentRequestId = 0
const loadJoke = async () => {
joke.value = await getJoke(route.params.id)
const requestId = ++currentRequestId
loading.value = true
try {
const res = await getJoke(route.params.id)
// 只接受最新请求的响应
if (requestId !== currentRequestId) return
joke.value = res
liked.value = false
loading.value = false
} catch (e) {
if (requestId !== currentRequestId) return
loading.value = false
// 笑话不存在时返回首页
if (e?.detail === '笑话不存在' || e?.response?.status === 404) {
router.push('/')
}
}
}
const handleRandom = () => {
router.push('/')
setTimeout(() => window.location.reload(), 100)
const handleLike = async () => {
if (liked.value || !joke.value) return
try {
const res = await likeJoke(route.params.id)
if (joke.value && res?.like_count !== undefined) {
joke.value.like_count = res.like_count
}
liked.value = true
ElMessage.success('点赞成功')
} catch (e) {
ElMessage.error('点赞失败,请重试')
}
}
const handleNext = async () => {
try {
const res = await getJokes({ page: 1, page_size: 50 })
if (res.items && res.items.length) {
const rnd = res.items[Math.floor(Math.random() * res.items.length)]
router.push(`/detail/${rnd.id}`)
}
} catch (e) { /* ignore */ }
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
onMounted(() => loadJoke())
watch(() => route.params.id, () => loadJoke())
</script>
<style scoped>
.joke-detail {
background: white;
padding: 30px;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
.detail-page {
display: flex;
justify-content: center;
padding: 0;
}
.tags { display: flex; gap: 10px; margin-bottom: 15px; }
.tag { padding: 6px 14px; border-radius: 15px; font-size: 13px; }
.type-tag { background: #FFF3E0; color: #FF9500; }
.crowd-tag { background: #E3F2FD; color: #2196F3; }
.title { font-size: 24px; margin-bottom: 20px; color: #333; }
.content {
line-height: 1.8;
.joke-detail-card {
width: 100%;
max-width: 780px;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 40px 48px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 20px;
transition: background 0.3s, box-shadow 0.3s;
}
.detail-tags { display: flex; gap: 10px; }
.detail-title {
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.4;
letter-spacing: 0.5px;
transition: color 0.3s;
}
.detail-content {
font-size: 17px;
color: #444;
line-height: 2;
color: var(--text-secondary);
white-space: pre-wrap;
letter-spacing: 0.3px;
transition: color 0.3s;
}
.actions { margin-top: 30px; }
.action-btn {
padding: 12px 30px;
background: linear-gradient(135deg, #FF9500, #FFB800);
.detail-actions { display: flex; gap: 14px; padding-top: 8px; }
.action-primary {
padding: 12px 28px;
background: var(--primary);
color: white;
border: none;
border-radius: 25px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.action-btn:hover { opacity: 0.9; }
.meta { margin-top: 20px; color: #999; font-size: 14px; text-align: center; }
.action-primary:hover {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(255,107,0,0.3);
}
.action-secondary {
padding: 12px 28px;
background: var(--bg-card);
color: var(--text-secondary);
border: 1.5px solid var(--border);
border-radius: 25px;
font-size: 15px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.action-secondary:hover {
border-color: var(--primary);
color: var(--primary);
}
.detail-meta {
text-align: center;
color: var(--text-muted);
font-size: 13px;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding-top: 8px;
border-top: 1px solid var(--border);
transition: color 0.3s, border-color 0.3s;
}
.sep { color: #ddd; }
.like-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
padding: 0;
transition: color 0.2s;
}
.like-btn:hover { color: #e74c3c; }
.like-btn.liked { color: #e74c3c; }
.like-btn .heart {
font-size: 14px;
color: #ccc;
transition: color 0.2s, transform 0.2s;
}
.like-btn:hover .heart { color: #e74c3c; }
.like-btn.liked .heart {
color: #e74c3c;
animation: pop 0.3s ease;
}
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
/* 加载状态 */
.detail-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 80px;
color: var(--text-muted);
}
.spinner {
width: 28px; height: 28px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="feedback-page">
<h1 class="page-title">意见建议</h1>
<p class="page-subtitle">您的反馈是我们前进的动力</p>
<div class="form-card">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
label-position="left"
>
<el-form-item label="昵称" prop="name">
<el-input v-model="form.name" placeholder="您的昵称(选填)" maxlength="50" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="您的邮箱(选填)" maxlength="100" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="6"
placeholder="请详细描述您的意见或建议..."
maxlength="2000"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : '提交反馈' }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { submitFeedback } from '@/api/feedback'
const formRef = ref(null)
const submitting = ref(false)
const form = reactive({
name: '',
email: '',
content: ''
})
const rules = {
content: [
{ required: true, message: '请输入反馈内容', trigger: 'blur' },
{ min: 2, message: '内容至少 2 个字符', trigger: 'blur' }
]
}
const handleSubmit = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await submitFeedback({
name: form.name || undefined,
email: form.email || undefined,
content: form.content
})
ElMessage.success('感谢您的反馈!')
form.name = ''
form.email = ''
form.content = ''
formRef.value.resetFields()
} catch (e) {
ElMessage.error('提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.feedback-page {
max-width: 640px;
margin: 0 auto;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 24px;
}
.form-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 28px;
box-shadow: var(--shadow);
}
</style>
+19 -7
View File
@@ -153,7 +153,8 @@ async function handleGenerate() {
const exists = favorites.value.find(f => f.content === res.content)
isFavorited.value = !!exists
} catch (e) {
ElMessage.error(e.detail || '生成失败,请重试')
const msg = e?.detail || e?.message || '生成失败,请重试'
ElMessage.error(msg)
generatedJoke.value = null
} finally {
loading.value = false
@@ -168,20 +169,31 @@ function handleFavorite() {
ElMessage.info('已经收藏过了')
return
}
favorites.value.unshift({
const newFavorite = {
id: Date.now().toString(),
title: generatedJoke.value.title,
content: generatedJoke.value.content,
created_at: new Date().toISOString()
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true
ElMessage.success('收藏成功')
}
favorites.value.unshift(newFavorite)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true
ElMessage.success('收藏成功')
} catch (e) {
// 处理 localStorage 满的情况
favorites.value.shift() // 移除刚添加的
ElMessage.error('收藏失败,存储空间可能已满,请清理部分收藏')
}
}
function removeFavorite(i) {
favorites.value.splice(i, 1)
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
} catch (e) {
ElMessage.error('保存失败')
}
// 检查当前生成的是否也从收藏中被删除了
if (generatedJoke.value) {
const stillExists = favorites.value.find(f => f.content === generatedJoke.value.content)
+93 -31
View File
@@ -1,14 +1,30 @@
<template>
<div class="joke-card" @click="$router.push(`/detail/${joke.id}`)">
<!-- 顶部标签 -->
<div class="card-header">
<span class="tag type-tag">{{ joke.type_name || '段子' }}</span>
<span class="tag crowd-tag" v-if="joke.crowd_name">{{ joke.crowd_name }}</span>
<span class="tag tag-type" v-for="(name, i) in (joke.type_names||[])" :key="'t'+i">{{ name }}</span>
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div>
<h3 class="title">{{ joke.title }}</h3>
<p class="content">{{ joke.content }}</p>
<!-- 标题 -->
<h3 class="card-title">{{ joke.title }}</h3>
<!-- 内容 -->
<p class="card-content">{{ joke.content }}</p>
<!-- 底部信息 -->
<div class="card-footer">
<span class="views">浏览 {{ joke.view_count }}</span>
<span class="likes"> {{ joke.like_count }}</span>
<div class="card-stats">
<span class="stat">
<span class="stat-icon">&#x1F441;</span>
{{ joke.view_count || 0 }}
</span>
<span class="stat">
<span class="stat-icon">&#x2764;</span>
{{ joke.like_count || 0 }}
</span>
</div>
<span class="read-more">阅读 &#x2192;</span>
</div>
</div>
</template>
@@ -21,41 +37,87 @@ defineProps({
<style scoped>
.joke-card {
background: white;
border-radius: 12px;
padding: 20px;
background: var(--bg-card);
border-radius: var(--radius);
padding: 22px 24px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: all 0.25s;
box-shadow: var(--shadow);
border: 1px solid transparent;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
overflow: hidden;
}
.joke-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--primary);
transform: scaleY(0);
transition: transform 0.25s;
border-radius: 0 0 0 var(--radius);
}
.joke-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
transform: translateY(-3px);
box-shadow: var(--shadow-hover);
border-color: #FFF0E6;
}
.card-header { display: flex; gap: 8px; margin-bottom: 12px; }
.tag {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
}
.type-tag { background: #FFF3E0; color: #FF9500; }
.crowd-tag { background: #E3F2FD; color: #2196F3; }
.title { font-size: 18px; margin-bottom: 10px; color: #333; }
.content {
color: #666;
line-height: 1.6;
.joke-card:hover::before { transform: scaleY(1); }
/* 标签 */
.card-header { display: flex; gap: 8px; flex-wrap: wrap; }
/* 标题 */
.card-title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 内容 */
.card-content {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.7;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
/* 底部 */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #f0f0f0;
color: #999;
font-size: 13px;
padding-top: 12px;
border-top: 1px solid var(--border);
margin-top: auto;
}
.card-stats { display: flex; gap: 14px; }
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.stat-icon { font-size: 13px; }
.read-more {
font-size: 12px;
color: var(--primary);
font-weight: 600;
opacity: 0;
transition: opacity 0.2s;
}
.joke-card:hover .read-more { opacity: 1; }
</style>
+131 -36
View File
@@ -1,68 +1,163 @@
<template>
<div class="home">
<div class="filter-bar">
<div class="filter-group">
<span>类型:</span>
<el-select v-model="filter.type_id" placeholder="全部" clearable @change="loadJokes">
<el-option v-for="t in types" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</div>
<div class="filter-group">
<span>人群:</span>
<el-select v-model="filter.crowd_id" placeholder="全部" clearable @change="loadJokes">
<el-option v-for="c in crowds" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</div>
<!-- 筛选信息栏 -->
<div class="filter-bar" v-if="filterText">
<span class="filter-text">{{ filterText }}</span>
<span class="filter-count"> {{ jokeStore.total }} </span>
<el-button link type="primary" @click="clearFilter" v-if="jokeStore.total > 0">清除筛选</el-button>
</div>
<div class="joke-grid">
<!-- 笑话网格 -->
<div class="joke-grid" v-if="jokeStore.jokes.length">
<JokeCard v-for="joke in jokeStore.jokes" :key="joke.id" :joke="joke" />
</div>
<el-pagination
v-if="jokeStore.total > 20"
:current-page="page"
:page-size="20"
:total="jokeStore.total"
layout="prev, pager, next"
@current-change="loadJokes"
style="margin-top: 20px; justify-content: center; display: flex"
/>
<!-- 空状态 -->
<div class="empty-state" v-else-if="!jokeStore.loading && !filterText">
<div class="empty-icon">&#x1F60A;</div>
<div class="empty-text">暂无笑话内容</div>
<div class="empty-sub">试试从左侧选择分类筛选</div>
</div>
<div class="empty-state" v-else-if="!jokeStore.loading && filterText">
<div class="empty-icon">&#x1F50D;</div>
<div class="empty-text">此分类下暂无笑话</div>
<div class="empty-sub">试试其他分类或清除筛选</div>
</div>
<!-- 加载状态 -->
<div class="loading-state" v-if="jokeStore.loading">
<div class="spinner"></div>
<span>加载中...</span>
</div>
<!-- 分页 -->
<div class="pagination-wrap" v-if="jokeStore.total > pageSize">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="jokeStore.total"
layout="prev, pager, next, total"
@current-change="loadJokes"
background
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useRoute } from 'vue-router'
import { useJokeStore } from '@/stores/joke'
import { getTypes, getCrowds } from '@/api/category'
import JokeCard from './components/JokeCard.vue'
const jokeStore = useJokeStore()
const route = useRoute()
const allTypes = inject('allTypes')
const allCrowds = inject('allCrowds')
const types = ref([])
const crowds = ref([])
const page = ref(1)
const filter = ref({ type_id: null, crowd_id: null })
const pageSize = 20
const filterTypeIds = computed(() => route.query.type_ids ? String(route.query.type_ids).split(',').map(Number) : [])
const filterCrowdIds = computed(() => route.query.crowd_ids ? String(route.query.crowd_ids).split(',').map(Number) : [])
const filterText = computed(() => {
if (filterTypeIds.value.length) {
return filterTypeIds.value.map(id => types.value.find(t => t.id === id)?.name).filter(Boolean).join(' / ')
}
if (filterCrowdIds.value.length) {
return filterCrowdIds.value.map(id => crowds.value.find(c => c.id === id)?.name).filter(Boolean).join(' / ')
}
return ''
})
const loadJokes = async (p = 1) => {
page.value = p
await jokeStore.fetchJokes({ page: p, page_size: 20, ...filter.value })
const params = { page: p, page_size: pageSize }
if (filterTypeIds.value.length) params.type_ids = filterTypeIds.value.join(',')
if (filterCrowdIds.value.length) params.crowd_ids = filterCrowdIds.value.join(',')
await jokeStore.fetchJokes(params)
}
const clearFilter = () => {
window.history.pushState({}, '', '/')
loadJokes(1)
}
onMounted(async () => {
types.value = await getTypes()
crowds.value = await getCrowds()
types.value = allTypes.value?.length ? allTypes.value : await getTypes()
crowds.value = allCrowds.value?.length ? allCrowds.value : await getCrowds()
console.log('[DEBUG] types:', types.value.length, 'crowds:', crowds.value.length)
await loadJokes()
console.log('[DEBUG] jokes:', jokeStore.jokes.length, 'total:', jokeStore.total)
})
watch(() => route.query, () => {
page.value = 1
loadJokes(1)
}, { deep: true })
</script>
<style scoped>
.home { display: flex; flex-direction: column; gap: 20px; }
/* 筛选信息栏 */
.filter-bar {
background: var(--bg-card);
border-radius: var(--radius);
padding: 14px 20px;
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
align-items: center;
gap: 12px;
box-shadow: var(--shadow);
border-left: 3px solid var(--primary);
transition: background 0.3s;
}
.filter-group { display: flex; align-items: center; gap: 10px; }
.joke-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; }
.filter-text { font-size: 15px; font-weight: 700; color: var(--primary); }
.filter-count { font-size: 13px; color: var(--text-muted); margin-left: auto; }
/* 网格:双列自适应 */
.joke-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: background 0.3s;
}
.empty-icon { font-size: 64px; margin-bottom: 16px; }
.empty-text { font-size: 18px; color: var(--text-primary); font-weight: 600; margin-bottom: 8px; transition: color 0.3s; }
.empty-sub { font-size: 14px; color: var(--text-muted); }
/* 加载 */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
color: var(--text-muted);
}
.spinner {
width: 20px; height: 20px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 分页 */
.pagination-wrap { display: flex; justify-content: center; padding: 8px 0; }
:deep(.el-pagination.is-background .el-pager li.is-active) { background: var(--primary) !important; }
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="links-page">
<h1 class="page-title">友情链接</h1>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<span>加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="links.length === 0" class="empty-state">
<p>暂无友情链接</p>
</div>
<!-- 链接网格 -->
<div v-else class="links-grid">
<a
v-for="link in links"
:key="link.id"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="link-card"
>
<div class="link-name">{{ link.name }}</div>
<div class="link-desc" v-if="link.description">{{ link.description }}</div>
</a>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getLinks } from '@/api/link'
const links = ref([])
const loading = ref(true)
onMounted(async () => {
try {
links.value = await getLinks()
} catch (e) {
console.warn('[Links] Failed to load:', e)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.links-page {
max-width: 900px;
margin: 0 auto;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 24px;
color: var(--text-primary);
}
.loading-state,
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 15px;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.link-card {
display: block;
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
text-decoration: none;
box-shadow: var(--shadow);
transition: box-shadow 0.25s, transform 0.25s;
}
.link-card:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
.link-name {
font-size: 16px;
font-weight: 600;
color: var(--primary);
margin-bottom: 8px;
}
.link-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
</style>
+123 -32
View File
@@ -1,67 +1,158 @@
<template>
<div class="search-page">
<!-- 搜索框 -->
<div class="search-box">
<el-input
v-model="keyword"
placeholder="搜索笑话..."
placeholder="输入关键词搜索笑话..."
size="large"
@keyup.enter="handleSearch"
class="search-input"
>
<template #append>
<el-button @click="handleSearch">搜索</el-button>
<el-button class="search-btn-inner" @click="handleSearch">搜索</el-button>
</template>
</el-input>
</div>
<div class="search-results" v-if="results.length > 0">
<div
v-for="joke in results"
:key="joke.id"
class="result-item"
@click="$router.push(`/detail/${joke.id}`)"
>
<h3>{{ joke.title }}</h3>
<p>{{ joke.content }}</p>
<div class="hot-words" v-if="!searched">
<span>热门</span>
<span v-for="w in hotWords" :key="w" class="hot-word" @click="quickSearch(w)">{{ w }}</span>
</div>
</div>
<el-empty v-else-if="searched" description="未找到相关笑话" />
<!-- 结果 -->
<div class="results-area" v-if="searched">
<div class="results-count" v-if="results.length > 0">
找到 <strong>{{ results.length }}</strong> 条相关笑话
</div>
<div class="joke-grid" v-if="results.length">
<JokeCard v-for="j in results" :key="j.id" :joke="j" />
</div>
<div class="empty-state" v-else>
<div class="empty-icon">&#x1F50D;</div>
<div>未找到相关笑话换个关键词试试吧</div>
</div>
</div>
<!-- 默认提示 -->
<div class="search-hint" v-if="!searched">
<div class="hint-icon">&#x1F50D;</div>
<div class="hint-text">输入关键词开始搜索</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getJokes } from '@/api/joke'
import JokeCard from '../home/components/JokeCard.vue'
const route = useRoute()
const router = useRouter()
const keyword = ref(route.query.q || '')
const keyword = ref('')
const results = ref([])
const searched = ref(false)
const hotWords = ['冷笑话', '段子', '谐音梗', '职场', '校园']
const handleSearch = async () => {
if (!keyword.value.trim()) return
searched.value = true
const res = await getJokes({ page: 1, page_size: 50 })
results.value = res.items.filter(j =>
j.title.includes(keyword.value) || j.content.includes(keyword.value)
)
try {
const res = await getJokes({ page: 1, page_size: 100 })
const kw = keyword.value.toLowerCase()
results.value = (res.items || []).filter(j =>
(j.title && j.title.toLowerCase().includes(kw)) ||
(j.content && j.content.toLowerCase().includes(kw))
)
} catch (e) {
results.value = []
}
}
if (keyword.value) handleSearch()
const quickSearch = (w) => {
keyword.value = w
handleSearch()
}
onMounted(() => {
if (route.query.q) {
keyword.value = route.query.q
handleSearch()
}
})
watch(() => route.query.q, (q) => {
if (q) {
keyword.value = q
handleSearch()
}
})
</script>
<style scoped>
.search-box { max-width: 600px; margin: 0 auto 30px; }
.search-results { display: flex; flex-direction: column; gap: 15px; }
.result-item {
background: white;
padding: 20px;
.search-page { display: flex; flex-direction: column; gap: 20px; }
/* 搜索框 */
.search-box {
background: var(--bg-card);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
transition: background 0.3s;
}
.search-input { max-width: 600px; }
:deep(.el-input__wrapper) { border-radius: 24px !important; }
:deep(.el-input-group__append) { border-radius: 0 24px 24px 0 !important; }
.search-btn-inner {
background: var(--primary) !important;
color: white !important;
border: none !important;
font-weight: 600;
}
.hot-words {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 13px;
color: var(--text-muted);
flex-wrap: wrap;
}
.hot-word {
padding: 3px 12px;
background: var(--bg-page);
border-radius: 12px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: transform 0.2s;
transition: all 0.2s;
font-size: 13px;
}
.result-item:hover { transform: translateX(5px); }
.result-item h3 { margin-bottom: 8px; color: #333; }
.result-item p { color: #666; line-height: 1.5; }
.hot-word:hover { background: var(--primary); color: white; }
/* 结果 */
.results-count {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 12px;
}
.results-count strong { color: var(--primary); }
.joke-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
/* 空/默认 */
.empty-state, .search-hint {
text-align: center;
padding: 60px 20px;
background: var(--bg-card);
border-radius: var(--radius);
transition: background 0.3s;
}
.empty-icon, .hint-icon { font-size: 56px; margin-bottom: 16px; }
.empty-state > div { font-size: 16px; color: var(--text-muted); }
.hint-text { font-size: 18px; font-weight: 600; color: var(--text-primary); }
</style>
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://localhost:8001',
changeOrigin: true
}
}