You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

393 lines
8.7 KiB

<script setup lang="ts">
interface CharacterListItem {
id: number;
slug: string;
name: string;
avatar?: string | null;
description?: string | null;
tags?: string[];
isPublic: number;
enabled: number;
sortOrder: number;
defaultModel?: {
modelName: string;
modelId: string;
providerName: string;
providerStatus: string;
apiKeySet: boolean;
} | null;
}
const { $toast } = useNuxtApp() as any;
const { data, refresh } = await useHttpFetch("/api/admin/characters");
const list = computed<CharacterListItem[]>(() => {
const raw = data.value as any;
return raw?.data?.list ?? raw?.list ?? [];
});
const confirmShow = ref(false);
const confirmMsg = ref("");
let confirmAction: (() => void) | null = null;
function showConfirm(msg: string, action: () => void) {
confirmMsg.value = msg;
confirmAction = action;
confirmShow.value = true;
}
function onConfirm() {
confirmAction?.();
confirmShow.value = false;
}
async function handleToggle(item: CharacterListItem) {
try {
await $fetch(`/api/characters/${item.slug}`, {
method: "PUT",
body: { enabled: item.enabled ? 0 : 1 },
});
$toast?.success?.(item.enabled ? "已禁用" : "已启用");
refresh();
} catch (e: any) {
$toast?.error?.(e?.data?.message ?? "操作失败");
}
}
function handleDelete(item: CharacterListItem) {
showConfirm(`确定删除角色「${item.name}」?此操作不可恢复。`, async () => {
try {
await $fetch(`/api/characters/${item.slug}`, { method: "DELETE" });
$toast?.success?.("删除成功");
refresh();
} catch (e: any) {
$toast?.error?.(e?.data?.message ?? "删除失败");
}
});
}
</script>
<template>
<div class="admin-characters-page">
<header class="page-header">
<div class="page-header-content">
<h1 class="page-title">角色卡管理</h1>
<p class="page-subtitle">管理角色卡(人设 / 世界观 / 开场白 / 对话示例)</p>
</div>
<NuxtLink to="/admin/characters/new" class="btn-primary">
<Icon name="lucide:plus" />
创建角色
</NuxtLink>
</header>
<div class="table-card">
<table class="data-table">
<thead>
<tr>
<th class="text-left">名称</th>
<th class="text-left">Slug</th>
<th class="text-left">默认模型</th>
<th class="text-left">状态</th>
<th class="text-left">公开</th>
<th class="text-left">排序</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="list.length === 0">
<td colspan="7" class="text-center empty-cell">暂无角色卡</td>
</tr>
<tr v-for="c in list" :key="c.id" class="table-row">
<td class="name-cell">
<div class="name-title">{{ c.name }}</div>
<div class="name-desc">{{ c.description || "—" }}</div>
</td>
<td class="slug-cell">{{ c.slug }}</td>
<td class="model-cell">
<template v-if="c.defaultModel">
<div class="model-name">{{ c.defaultModel.modelName }}</div>
<div class="model-meta">
<span class="model-provider">{{ c.defaultModel.providerName }}</span>
</div>
</template>
<span v-else class="model-none">— 未设置 —</span>
</td>
<td class="status-cell">
<span :class="['status-badge', c.enabled ? 'badge-active' : 'badge-paused']">
{{ c.enabled ? "启用" : "禁用" }}
</span>
</td>
<td class="public-cell">
<span :class="['status-badge', c.isPublic ? 'badge-callable' : 'badge-muted']">
{{ c.isPublic ? "公开" : "私密" }}
</span>
</td>
<td class="sort-cell">{{ c.sortOrder }}</td>
<td class="actions-cell">
<div class="action-buttons">
<NuxtLink :to="`/admin/characters/${c.slug}`" class="action-btn">编辑</NuxtLink>
<button class="action-btn" @click="handleToggle(c)">
{{ c.enabled ? "禁用" : "启用" }}
</button>
<button class="action-btn action-btn-danger" @click="handleDelete(c)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<BoDialog v-model:show="confirmShow" :mask-can-close="true">
<div class="confirm-dialog">
<h3 class="text-[16px] font-medium text-[#141413]">确认操作</h3>
<p class="mt-3 text-[14px] text-[#3d3d3a]">{{ confirmMsg }}</p>
<div class="mt-6 flex justify-end gap-2">
<button class="cancel-btn" @click="confirmShow = false">取消</button>
<button class="ok-btn" @click="onConfirm">确定</button>
</div>
</div>
</BoDialog>
</div>
</template>
<style scoped>
.admin-characters-page {
padding: 40px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 32px;
}
.page-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 400;
color: var(--color-ink);
margin: 0;
}
.page-subtitle {
font-size: 15px;
color: var(--color-muted);
margin: 4px 0 0;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease;
}
.btn-primary:hover {
background: var(--color-primary-active);
}
.btn-primary :deep(svg) {
width: 16px;
height: 16px;
}
.table-card {
background: var(--color-surface-card);
border-radius: 12px;
overflow: hidden;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
padding: 14px 16px;
font-size: 12px;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--color-surface-soft);
border-bottom: 1px solid var(--color-hairline);
text-align: left;
}
.data-table td {
padding: 14px 16px;
font-size: 14px;
color: var(--color-body-strong);
border-bottom: 1px solid var(--color-hairline);
}
.table-row:last-child td {
border-bottom: none;
}
.table-row:hover td {
background: var(--color-surface-soft);
}
.empty-cell {
text-align: center;
color: var(--color-muted);
padding: 40px 16px;
}
.name-cell {
font-weight: 500;
color: var(--color-ink);
}
.name-desc {
font-size: 12px;
color: var(--color-muted);
font-weight: 400;
margin-top: 2px;
}
.slug-cell {
font-family: var(--font-mono, monospace);
font-size: 13px;
color: var(--color-muted);
}
.model-name {
font-weight: 500;
color: var(--color-ink);
}
.model-provider {
font-size: 12px;
color: var(--color-muted);
}
.model-none {
font-size: 12px;
color: var(--color-muted);
}
.sort-cell {
color: var(--color-muted);
font-size: 13px;
}
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-active {
background: #e6f4ea;
color: #137333;
}
.badge-paused {
background: #fef7e0;
color: #b06000;
}
.badge-callable {
background: #f3e8ff;
color: #7c3aed;
}
.badge-muted {
background: #f0f0f0;
color: #999;
}
.actions-cell {
text-align: right;
}
.action-buttons {
display: inline-flex;
gap: 6px;
}
.action-btn {
padding: 4px 12px;
font-size: 13px;
border: 1px solid var(--color-hairline);
border-radius: 6px;
background: var(--color-surface-card);
color: var(--color-body-strong);
cursor: pointer;
text-decoration: none;
transition: all 0.15s ease;
}
.action-btn:hover {
background: var(--color-surface-soft);
border-color: var(--color-primary);
color: var(--color-primary);
}
.action-btn-danger:hover {
background: #fef0f0;
border-color: #e53e3e;
color: #e53e3e;
}
.confirm-dialog {
margin: 16px;
background: #fff;
border-radius: 12px;
padding: 24px;
border: 1px solid #e6dfd8;
}
.cancel-btn {
padding: 8px 16px;
background: #faf9f5;
border: 1px solid #e6dfd8;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.ok-btn {
padding: 8px 16px;
background: #cc785c;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.ok-btn:hover {
background: #a9583e;
}
@media (max-width: 768px) {
.admin-characters-page {
padding: 16px;
}
.page-header {
flex-direction: column;
gap: 12px;
}
.table-card {
overflow-x: auto;
}
.data-table {
min-width: 700px;
}
}
</style>