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:
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"hash": "529e4e6a",
|
||||
"configHash": "47bdc112",
|
||||
"lockfileHash": "81bd2f5f",
|
||||
"browserHash": "e99f2664",
|
||||
"optimized": {
|
||||
"axios": {
|
||||
"src": "../../axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "64a84452",
|
||||
"needsInterop": false
|
||||
},
|
||||
"element-plus": {
|
||||
"src": "../../element-plus/es/index.mjs",
|
||||
"file": "element-plus.js",
|
||||
"fileHash": "d8579f02",
|
||||
"needsInterop": false
|
||||
},
|
||||
"pinia": {
|
||||
"src": "../../pinia/dist/pinia.mjs",
|
||||
"file": "pinia.js",
|
||||
"fileHash": "cc8debcd",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue": {
|
||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "7c6ca729",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../vue-router/dist/vue-router.mjs",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "bb6a478b",
|
||||
"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": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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')
|
||||
export const createType = data => request.post('/admin/types', data)
|
||||
export const updateType = (id, data) => request.put(`/admin/types/${id}`, data)
|
||||
export const deleteType = id => request.delete(`/admin/types/${id}`)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from './request'
|
||||
|
||||
export const getFeedbacks = () => request.get('/admin/feedbacks')
|
||||
export const deleteFeedback = id => request.delete('/admin/feedbacks/' + id)
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from './request'
|
||||
|
||||
export const getLinks = () => request.get('/admin/links')
|
||||
export const getLink = id => request.get('/admin/links/' + id)
|
||||
export const createLink = data => request.post('/admin/links', data)
|
||||
export const updateLink = (id, data) => request.put('/admin/links/' + id, data)
|
||||
export const deleteLink = id => request.delete('/admin/links/' + id)
|
||||
@@ -13,15 +13,15 @@ request.interceptors.request.use(config => {
|
||||
return config
|
||||
})
|
||||
|
||||
request.interceptors.response.use({
|
||||
successHandler: res => res.data,
|
||||
errorHandler: err => {
|
||||
request.interceptors.response.use(
|
||||
res => res.data,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import request from './request'
|
||||
|
||||
export const getSettings = () => request.get('/admin/settings')
|
||||
export const getActiveSetting = () => request.get('/admin/settings/active')
|
||||
export const createSetting = data => request.post('/admin/settings', data)
|
||||
export const updateSetting = (id, data) => request.put(`/admin/settings/${id}`, data)
|
||||
export const toggleSetting = id => request.put(`/admin/settings/${id}/toggle`)
|
||||
export const deleteSetting = id => request.delete(`/admin/settings/${id}`)
|
||||
@@ -16,6 +16,10 @@ const routes = [
|
||||
{ path: 'jokes/edit/:id?', name: 'JokeEdit', component: () => import('@/views/joke/Edit.vue') },
|
||||
{ path: 'category/type', name: 'CategoryType', component: () => import('@/views/category/Type.vue') },
|
||||
{ path: 'category/crowd', name: 'CategoryCrowd', component: () => import('@/views/category/Crowd.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('@/views/setting/Index.vue') },
|
||||
{ path: 'links', name: 'LinkList', component: () => import('@/views/links/List.vue') },
|
||||
{ path: 'links/edit/:id?', name: 'LinkEdit', component: () => import('@/views/links/Edit.vue') },
|
||||
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedback/List.vue') },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,14 +10,6 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
<div class="stat-label">待审核</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
@@ -26,6 +18,24 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
<div class="stat-label">待审核</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.rejected }}</div>
|
||||
<div class="stat-label">已拒绝</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
@@ -34,6 +44,14 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.likes }}</div>
|
||||
<div class="stat-label">总点赞</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,11 +60,22 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getStats } from '@/api/joke'
|
||||
|
||||
const stats = ref({ total: 0, pending: 0, approved: 0, views: 0 })
|
||||
const stats = ref({ total: 0, pending: 0, approved: 0, rejected: 0, views: 0, likes: 0 })
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getStats()
|
||||
stats.value = res
|
||||
try {
|
||||
const res = await getStats()
|
||||
stats.value = {
|
||||
total: res.total_jokes || 0,
|
||||
pending: res.pending_jokes || 0,
|
||||
approved: res.approved_jokes || 0,
|
||||
rejected: res.rejected_jokes || 0,
|
||||
views: res.total_views || 0,
|
||||
likes: res.total_likes || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取统计数据失败', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="feedback-list">
|
||||
<div class="header">
|
||||
<h2>反馈管理</h2>
|
||||
</div>
|
||||
<el-table :data="feedbacks" v-loading="loading" style="margin-top: 20px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" width="120" />
|
||||
<el-table-column prop="email" label="邮箱" width="200" />
|
||||
<el-table-column prop="content" label="内容" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="提交时间" width="180" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getFeedbacks, deleteFeedback } from '@/api/feedback'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const feedbacks = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const loadFeedbacks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
feedbacks.value = await getFeedbacks()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确定删除?')
|
||||
await deleteFeedback(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadFeedbacks()
|
||||
}
|
||||
|
||||
onMounted(() => loadFeedbacks())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -8,13 +8,13 @@
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input v-model="form.content" type="textarea" :rows="6" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="type_id">
|
||||
<el-select v-model="form.type_id" placeholder="选择类型">
|
||||
<el-form-item label="类型" prop="type_ids">
|
||||
<el-select v-model="form.type_ids" multiple placeholder="选择类型(可多选)">
|
||||
<el-option v-for="t in types" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="人群" prop="crowd_id">
|
||||
<el-select v-model="form.crowd_id" placeholder="选择人群">
|
||||
<el-form-item label="人群" prop="crowd_ids">
|
||||
<el-select v-model="form.crowd_ids" multiple placeholder="选择人群(可多选)">
|
||||
<el-option v-for="c in crowds" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -46,7 +46,7 @@ const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '', content: '', type_id: null, crowd_id: null, status: 'pending'
|
||||
title: '', content: '', type_ids: [], crowd_ids: [], status: 'pending'
|
||||
})
|
||||
|
||||
const types = ref([])
|
||||
|
||||
@@ -7,8 +7,16 @@
|
||||
<el-table :data="jokeStore.jokes" v-loading="jokeStore.loading" style="margin-top: 20px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="type_name" label="类型" width="100" />
|
||||
<el-table-column prop="crowd_name" label="人群" width="100" />
|
||||
<el-table-column label="类型" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="name in (row.type_names || [])" :key="name" size="small" style="margin:1px">{{ name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="人群" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="name in (row.crowd_names || [])" :key="name" size="small" type="success" style="margin:1px">{{ name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'approved' ? 'success' : row.status === 'pending' ? 'warning' : 'danger'">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="link-edit">
|
||||
<h2>{{ isEdit ? '编辑友链' : '添加友链' }}</h2>
|
||||
<el-form :model="form" ref="formRef" label-width="120px" style="margin-top: 20px; max-width: 600px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="URL" prop="url">
|
||||
<el-input v-model="form.url" placeholder="https://" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort_order">
|
||||
<el-input-number v-model="form.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getLink, createLink, updateLink } from '@/api/link'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '', url: '', description: '', sort_order: 0
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
if (route.params.id) {
|
||||
isEdit.value = true
|
||||
const res = await getLink(route.params.id)
|
||||
Object.assign(form, res)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isEdit.value) {
|
||||
await updateLink(route.params.id, form)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createLink(form)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
router.push('/links')
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="link-list">
|
||||
<div class="header">
|
||||
<h2>友链管理</h2>
|
||||
<el-button type="primary" @click="$router.push('/links/edit')">添加友链</el-button>
|
||||
</div>
|
||||
<el-table :data="links" v-loading="loading" style="margin-top: 20px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="url" label="URL" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<a :href="row.url" target="_blank">{{ row.url }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="$router.push(`/links/edit/${row.id}`)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getLinks, deleteLink } from '@/api/link'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const links = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const loadLinks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
links.value = await getLinks()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确定删除?')
|
||||
await deleteLink(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadLinks()
|
||||
}
|
||||
|
||||
onMounted(() => loadLinks())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="setting-page">
|
||||
<h2>AI 配置</h2>
|
||||
|
||||
<!-- AI 配置表单 -->
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>AI 配置</span>
|
||||
</template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="提供商">
|
||||
<el-select v-model="form.provider" style="width: 200px">
|
||||
<el-option label="NVIDIA NIM" value="nvidia" />
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 地址">
|
||||
<el-input v-model="form.api_base" placeholder="https://integrate.api.nvidia.com/v1" style="width: 400px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API 密钥">
|
||||
<el-input v-model="form.api_key" type="password" show-password style="width: 400px" placeholder="nvapi-..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型名称">
|
||||
<el-input v-model="form.model_name" style="width: 300px" placeholder="nvidia/llama-3.1-nemotron-70b-instruct" />
|
||||
</el-form-item>
|
||||
<el-form-item label="温度">
|
||||
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大 Token">
|
||||
<el-input-number v-model="form.max_tokens" :min="1" :max="8192" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 爬虫配置 -->
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>爬虫配置</span>
|
||||
</template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="启用爬虫">
|
||||
<el-switch v-model="form.crawl_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="搜索关键词">
|
||||
<el-input
|
||||
v-model="form.crawl_keywords"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
style="width: 400px"
|
||||
placeholder="多个关键词用逗号分隔,如:冷笑话,段子,谐音梗"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="每轮页数">
|
||||
<el-input-number v-model="form.max_pages_per_run" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div style="margin-top: 20px">
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存配置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 历史配置 -->
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>历史配置</span>
|
||||
</template>
|
||||
<el-table :data="allSettings" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="provider" label="提供商" width="100" />
|
||||
<el-table-column prop="model_name" label="模型" />
|
||||
<el-table-column prop="api_base" label="API 地址" show-overflow-tooltip />
|
||||
<el-table-column prop="is_active" label="激活" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="handleToggle(row)" v-if="!row.is_active">激活</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSettings, getActiveSetting, createSetting, updateSetting, toggleSetting, deleteSetting } from '@/api/setting'
|
||||
|
||||
const form = ref({
|
||||
provider: 'nvidia',
|
||||
api_base: 'https://integrate.api.nvidia.com/v1',
|
||||
api_key: '',
|
||||
model_name: 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
crawl_enabled: false,
|
||||
crawl_keywords: '冷笑话,段子,谐音梗',
|
||||
max_pages_per_run: 3,
|
||||
})
|
||||
|
||||
const allSettings = ref([])
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const active = await getActiveSetting()
|
||||
editingId.value = active.id
|
||||
form.value = {
|
||||
provider: active.provider,
|
||||
api_base: active.api_base,
|
||||
api_key: active.api_key,
|
||||
model_name: active.model_name,
|
||||
temperature: active.temperature,
|
||||
max_tokens: active.max_tokens,
|
||||
crawl_enabled: active.crawl_enabled,
|
||||
crawl_keywords: active.crawl_keywords || '',
|
||||
max_pages_per_run: active.max_pages_per_run,
|
||||
}
|
||||
} catch (e) {
|
||||
// 没有激活配置,使用默认值
|
||||
}
|
||||
|
||||
try {
|
||||
allSettings.value = await getSettings()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateSetting(editingId.value, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
const created = await createSetting(form.value)
|
||||
editingId.value = created.id
|
||||
await toggleSetting(created.id) // 设为激活
|
||||
ElMessage.success('创建并激活成功')
|
||||
}
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败: ' + (e.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
provider: row.provider,
|
||||
api_base: row.api_base,
|
||||
api_key: row.api_key,
|
||||
model_name: row.model_name,
|
||||
temperature: row.temperature,
|
||||
max_tokens: row.max_tokens,
|
||||
crawl_enabled: row.crawl_enabled,
|
||||
crawl_keywords: row.crawl_keywords || '',
|
||||
max_pages_per_run: row.max_pages_per_run,
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (row) => {
|
||||
await ElMessageBox.confirm('确定激活此配置?')
|
||||
await toggleSetting(row.id)
|
||||
ElMessage.success('激活成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确定删除此配置?')
|
||||
await deleteSetting(id)
|
||||
ElMessage.success('删除成功')
|
||||
if (editingId.value === id) {
|
||||
editingId.value = null
|
||||
}
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setting-page { max-width: 900px }
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user