fix: resolve 10 code review issues

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

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

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

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
This commit is contained in:
bwstudio
2026-06-02 20:35:08 +08:00
parent 0b43973236
commit ceed63fcb0
144 changed files with 191660 additions and 270 deletions
+49
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
export {
__commonJS,
__export,
__toESM
};
//# sourceMappingURL=chunk-G3PMV62Z.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}
+13045
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+162
View File
@@ -0,0 +1,162 @@
// node_modules/@vue/devtools-api/lib/esm/env.js
function getDevtoolsGlobalHook() {
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
}
function getTarget() {
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
}
var isProxyAvailable = typeof Proxy === "function";
// node_modules/@vue/devtools-api/lib/esm/const.js
var HOOK_SETUP = "devtools-plugin:setup";
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
// node_modules/@vue/devtools-api/lib/esm/time.js
var supported;
var perf;
function isPerformanceSupported() {
var _a;
if (supported !== void 0) {
return supported;
}
if (typeof window !== "undefined" && window.performance) {
supported = true;
perf = window.performance;
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
supported = true;
perf = globalThis.perf_hooks.performance;
} else {
supported = false;
}
return supported;
}
function now() {
return isPerformanceSupported() ? perf.now() : Date.now();
}
// node_modules/@vue/devtools-api/lib/esm/proxy.js
var ApiProxy = class {
constructor(plugin, hook) {
this.target = null;
this.targetQueue = [];
this.onQueue = [];
this.plugin = plugin;
this.hook = hook;
const defaultSettings = {};
if (plugin.settings) {
for (const id in plugin.settings) {
const item = plugin.settings[id];
defaultSettings[id] = item.defaultValue;
}
}
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
let currentSettings = Object.assign({}, defaultSettings);
try {
const raw = localStorage.getItem(localSettingsSaveId);
const data = JSON.parse(raw);
Object.assign(currentSettings, data);
} catch (e) {
}
this.fallbacks = {
getSettings() {
return currentSettings;
},
setSettings(value) {
try {
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
} catch (e) {
}
currentSettings = value;
},
now() {
return now();
}
};
if (hook) {
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
if (pluginId === this.plugin.id) {
this.fallbacks.setSettings(value);
}
});
}
this.proxiedOn = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target.on[prop];
} else {
return (...args) => {
this.onQueue.push({
method: prop,
args
});
};
}
}
});
this.proxiedTarget = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target[prop];
} else if (prop === "on") {
return this.proxiedOn;
} else if (Object.keys(this.fallbacks).includes(prop)) {
return (...args) => {
this.targetQueue.push({
method: prop,
args,
resolve: () => {
}
});
return this.fallbacks[prop](...args);
};
} else {
return (...args) => {
return new Promise((resolve) => {
this.targetQueue.push({
method: prop,
args,
resolve
});
});
};
}
}
});
}
async setRealTarget(target) {
this.target = target;
for (const item of this.onQueue) {
this.target.on[item.method](...item.args);
}
for (const item of this.targetQueue) {
item.resolve(await this.target[item.method](...item.args));
}
}
};
// node_modules/@vue/devtools-api/lib/esm/index.js
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
const descriptor = pluginDescriptor;
const target = getTarget();
const hook = getDevtoolsGlobalHook();
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
} else {
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
list.push({
pluginDescriptor: descriptor,
setupFn,
proxy
});
if (proxy) {
setupFn(proxy.proxiedTarget);
}
}
}
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-YFT6OQ5R.js.map
File diff suppressed because one or more lines are too long
+71932
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+1561
View File
File diff suppressed because it is too large Load Diff
+7
View File
File diff suppressed because one or more lines are too long
+2247
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+348
View File
@@ -0,0 +1,348 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-UC4PQXLF.js";
import "./chunk-G3PMV62Z.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}
+2 -2
View File
@@ -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}`)
+4
View File
@@ -0,0 +1,4 @@
import request from './request'
export const getFeedbacks = () => request.get('/admin/feedbacks')
export const deleteFeedback = id => request.delete('/admin/feedbacks/' + id)
+7
View File
@@ -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)
+4 -4
View File
@@ -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
+8
View File
@@ -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}`)
+4
View File
@@ -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') },
]
}
]
+4 -1
View File
@@ -11,7 +11,10 @@ export const useJokeStore = defineStore('joke', {
async fetchJokes(params) {
this.loading = true
try {
const res = await getJokes(params)
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v != null && v !== '')
)
const res = await getJokes(cleanParams)
this.jokes = res.items
this.total = res.total
} finally {
+40 -11
View File
@@ -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>
+54
View File
@@ -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>
+5 -5
View File
@@ -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([])
+10 -2
View File
@@ -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'">
+60
View File
@@ -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>
+61
View File
@@ -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>
+198
View File
@@ -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>
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://localhost:8001',
changeOrigin: true
}
}