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:
@@ -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>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
+49
@@ -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
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+36
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
+13045
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+162
@@ -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
|
||||
+7
File diff suppressed because one or more lines are too long
+71932
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
+1561
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+2247
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+348
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '@/api/request'
|
||||
|
||||
export const submitFeedback = data => request.post('/feedback', data)
|
||||
+3
-2
@@ -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 })
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '@/api/request'
|
||||
|
||||
export const getLinks = () => request.get('/links')
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<aside class="rightbar">
|
||||
<!-- 热门笑话 -->
|
||||
<div class="rightbar-section">
|
||||
<div class="section-title">
|
||||
<span class="title-icon">🔥</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">👁 {{ 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">📋</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">👥</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">😊</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>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="sidebar-logo" @click="$router.push('/')">
|
||||
<span class="logo-icon">😊</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">🏠</span>
|
||||
<span>首页</span>
|
||||
</router-link>
|
||||
<router-link to="/category" class="nav-item" :class="{ active: $route.path === '/category' }">
|
||||
<span class="nav-icon">📋</span>
|
||||
<span>分类</span>
|
||||
</router-link>
|
||||
<router-link to="/search" class="nav-item" :class="{ active: $route.path === '/search' }">
|
||||
<span class="nav-icon">🔍</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>🎲</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>
|
||||
@@ -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">😊</span>
|
||||
<span class="brand-text">笑话大全</span>
|
||||
</div>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<div class="filter-section">
|
||||
<div class="section-label">
|
||||
<span>📋</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>👥</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>🎲</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>
|
||||
@@ -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">笑话大全 — 让快乐传递每一刻</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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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">📋 按类型</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">👥 按人群</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>
|
||||
@@ -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">📧</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">🌍</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">💬</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
@@ -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>🎲</span> 再看一条
|
||||
</button>
|
||||
<button class="action-secondary" @click="$router.push('/')">
|
||||
<span>🏠</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">❤</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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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">👁</span>
|
||||
{{ joke.view_count || 0 }}
|
||||
</span>
|
||||
<span class="stat">
|
||||
<span class="stat-icon">❤</span>
|
||||
{{ joke.like_count || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="read-more">阅读 →</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
@@ -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">😊</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">🔍</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>
|
||||
@@ -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
@@ -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">🔍</div>
|
||||
<div>未找到相关笑话,换个关键词试试吧</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认提示 -->
|
||||
<div class="search-hint" v-if="!searched">
|
||||
<div class="hint-icon">🔍</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
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user