merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)
保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Prompt = Awaited<ReturnType<typeof adminApi.systemPrompts>>['items'][number]
|
||||
type Source = Awaited<ReturnType<typeof adminApi.knowledgeSources>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Prompt[]>([])
|
||||
const selected = ref<Prompt | null>(null)
|
||||
const detailErr = ref('')
|
||||
|
||||
const ksLoading = ref(false)
|
||||
const ksError = ref('')
|
||||
const sources = ref<Source[]>([])
|
||||
const selectedSource = ref<Source | null>(null)
|
||||
const ksDetailErr = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.systemPrompts()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
ksLoading.value = true
|
||||
ksError.value = ''
|
||||
try {
|
||||
const res = await adminApi.knowledgeSources()
|
||||
sources.value = res.items || []
|
||||
} catch (e) {
|
||||
ksError.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
ksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openPrompt(id: string) {
|
||||
detailErr.value = ''
|
||||
try {
|
||||
selected.value = await adminApi.systemPrompt(id)
|
||||
} catch (e) {
|
||||
detailErr.value = e instanceof Error ? e.message : '详情失败'
|
||||
selected.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function openSource(id: string) {
|
||||
ksDetailErr.value = ''
|
||||
try {
|
||||
selectedSource.value = await adminApi.knowledgeSource(id)
|
||||
} catch (e) {
|
||||
ksDetailErr.value = e instanceof Error ? e.message : '详情失败'
|
||||
selectedSource.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadSources()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>AI 配置</h1>
|
||||
<p class="muted">SystemPrompt / KnowledgeSource 只读目录 · 本切片不可编辑发布</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>系统提示词</h2>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>版本</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in items" :key="p.id">
|
||||
<td><code>{{ p.code }}</code></td>
|
||||
<td>{{ p.title }}</td>
|
||||
<td>v{{ p.version }}</td>
|
||||
<td>{{ p.active ? '启用' : '停用' }}{{ p.system ? ' · 系统' : '' }}</td>
|
||||
<td><button class="btn" type="button" @click="openPrompt(p.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>正文</h2>
|
||||
<p v-if="detailErr" class="err">{{ detailErr }}</p>
|
||||
<template v-else-if="selected">
|
||||
<p class="meta">{{ selected.title }} · 更新 {{ fmtTime(selected.updated_at) }}</p>
|
||||
<pre>{{ selected.body }}</pre>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧提示词查看正文</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="ksLoading" class="muted ks-gap">知识源加载中…</p>
|
||||
<p v-else-if="ksError" class="err ks-gap">{{ ksError }}</p>
|
||||
<div v-else class="layout ks-gap">
|
||||
<div class="card">
|
||||
<h2>知识源</h2>
|
||||
<p v-if="!sources.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>类型</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sources" :key="s.id">
|
||||
<td><code>{{ s.code }}</code></td>
|
||||
<td>{{ s.title }}</td>
|
||||
<td>{{ s.source_kind }}</td>
|
||||
<td>{{ s.active ? '启用' : '停用' }}{{ s.system ? ' · 系统' : '' }}</td>
|
||||
<td><button class="btn" type="button" @click="openSource(s.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>知识源详情</h2>
|
||||
<p v-if="ksDetailErr" class="err">{{ ksDetailErr }}</p>
|
||||
<template v-else-if="selectedSource">
|
||||
<p class="meta">
|
||||
{{ selectedSource.title }} · {{ selectedSource.source_kind }} ·
|
||||
更新 {{ fmtTime(selectedSource.updated_at) }}
|
||||
</p>
|
||||
<p>{{ selectedSource.description || '无描述' }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧知识源查看详情</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 1.1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.ks-gap { margin-top: 1.5rem; }
|
||||
.meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 253, 251, 0.8);
|
||||
}
|
||||
code { font-size: 0.8rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
|
||||
type Detail = Awaited<ReturnType<typeof adminApi.askThread>>
|
||||
type Feedback = Awaited<ReturnType<typeof adminApi.askFeedback>>['items'][number]
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Thread[]>([])
|
||||
const selected = ref<Detail | null>(null)
|
||||
const detailErr = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const feedback = ref<Feedback[]>([])
|
||||
const rating = ref(4)
|
||||
const tag = ref('helpful')
|
||||
const note = ref('')
|
||||
const fbMsg = ref('')
|
||||
|
||||
const canWriteFeedback = () => auth.can('admin.ask.feedback.write')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [threads, fb] = await Promise.all([adminApi.askThreads(), adminApi.askFeedback()])
|
||||
items.value = threads.items || []
|
||||
feedback.value = fb.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openThread(id: string) {
|
||||
detailLoading.value = true
|
||||
detailErr.value = ''
|
||||
selected.value = null
|
||||
fbMsg.value = ''
|
||||
try {
|
||||
selected.value = await adminApi.askThread(id)
|
||||
} catch (e) {
|
||||
detailErr.value = e instanceof Error ? e.message : '详情失败'
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback() {
|
||||
if (!selected.value) return
|
||||
fbMsg.value = ''
|
||||
try {
|
||||
await adminApi.createAskFeedback(selected.value.id, {
|
||||
rating: rating.value,
|
||||
tag: tag.value,
|
||||
note: note.value || undefined,
|
||||
})
|
||||
fbMsg.value = '已提交质量反馈'
|
||||
note.value = ''
|
||||
const fb = await adminApi.askFeedback()
|
||||
feedback.value = fb.items || []
|
||||
} catch (e) {
|
||||
fbMsg.value = e instanceof Error ? e.message : '提交失败'
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>问答会话</h1>
|
||||
<p class="muted">AskSessionView · QualityFeedback · 不可改写消息</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card list">
|
||||
<p v-if="!items.length" class="muted">暂无会话</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>更新</th><th>场景</th><th>消息</th><th>用户</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in items" :key="t.id">
|
||||
<td>{{ fmtTime(t.updated_at) }}</td>
|
||||
<td>{{ t.scene || '—' }}</td>
|
||||
<td>{{ t.message_count }}</td>
|
||||
<td>
|
||||
<RouterLink :to="`/users/${t.user_id}`">{{ t.user_id.slice(0, 8) }}…</RouterLink>
|
||||
</td>
|
||||
<td><button class="btn" type="button" @click="openThread(t.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 class="sub">近期反馈</h2>
|
||||
<p v-if="!feedback.length" class="muted">暂无</p>
|
||||
<ul v-else class="fb-list">
|
||||
<li v-for="f in feedback.slice(0, 8)" :key="f.id">
|
||||
{{ f.source }} · {{ f.rating }}★ · {{ f.tag || '—' }} · {{ fmtTime(f.created_at) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card detail">
|
||||
<h2>会话详情</h2>
|
||||
<p v-if="detailLoading" class="muted">加载中…</p>
|
||||
<p v-else-if="detailErr" class="err">{{ detailErr }}</p>
|
||||
<template v-else-if="selected">
|
||||
<p class="meta">
|
||||
{{ selected.scene || '—' }} · {{ selected.message_count }} 条 ·
|
||||
<RouterLink :to="`/users/${selected.user_id}`">用户</RouterLink>
|
||||
</p>
|
||||
<ul class="msgs">
|
||||
<li v-for="m in selected.messages" :key="m.id" :class="m.role">
|
||||
<span class="role">{{ m.role }}</span>
|
||||
<p>{{ m.content }}</p>
|
||||
<time>{{ fmtTime(m.created_at) }}</time>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="canWriteFeedback()" class="fb-form">
|
||||
<h3>质量反馈</h3>
|
||||
<select v-model.number="rating">
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="3">3</option>
|
||||
<option :value="4">4</option>
|
||||
<option :value="5">5</option>
|
||||
</select>
|
||||
<select v-model="tag">
|
||||
<option value="helpful">helpful</option>
|
||||
<option value="off_topic">off_topic</option>
|
||||
<option value="unsafe">unsafe</option>
|
||||
<option value="other">other</option>
|
||||
</select>
|
||||
<input v-model="note" type="text" placeholder="备注(可选)" />
|
||||
<button class="btn" type="button" @click="submitFeedback">提交</button>
|
||||
<span class="muted">{{ fbMsg }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧会话查看消息</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
h2.sub { margin-top: 1rem; font-size: 0.95rem; }
|
||||
h3 { margin: 0.75rem 0 0.4rem; font-size: 0.95rem; }
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.list table { width: 100%; font-size: 0.88rem; }
|
||||
.meta { color: var(--muted); font-size: 0.85rem; }
|
||||
.msgs { list-style: none; margin: 0.75rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.65rem; }
|
||||
.msgs li {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
.msgs .role { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; }
|
||||
.msgs p { margin: 0.25rem 0; white-space: pre-wrap; word-break: break-word; }
|
||||
.msgs time { font-size: 0.75rem; color: var(--muted); }
|
||||
.msgs .assistant { background: rgba(255, 244, 242, 0.6); }
|
||||
.fb-list { margin: 0; padding-left: 1.1rem; font-size: 0.85rem; color: var(--muted); }
|
||||
.fb-form {
|
||||
display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center;
|
||||
margin-top: 0.85rem; padding-top: 0.75rem; border-top: 1px solid var(--line);
|
||||
}
|
||||
.fb-form select, .fb-form input {
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 0.4rem 0.55rem;
|
||||
}
|
||||
.fb-form input { min-width: 10rem; flex: 1; }
|
||||
@media (max-width: 900px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Banner = Awaited<ReturnType<typeof adminApi.banners>>['items'][number]
|
||||
type FeedSlot = Awaited<ReturnType<typeof adminApi.feedSlots>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Banner[]>([])
|
||||
const selected = ref<Banner | null>(null)
|
||||
|
||||
const slotsLoading = ref(false)
|
||||
const slotsError = ref('')
|
||||
const slots = ref<FeedSlot[]>([])
|
||||
const selectedSlot = ref<FeedSlot | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.banners()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
slotsLoading.value = true
|
||||
slotsError.value = ''
|
||||
try {
|
||||
const res = await adminApi.feedSlots()
|
||||
slots.value = res.items || []
|
||||
} catch (e) {
|
||||
slotsError.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
slotsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openBanner(id: string) {
|
||||
try {
|
||||
selected.value = await adminApi.banner(id)
|
||||
} catch {
|
||||
selected.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function openSlot(id: string) {
|
||||
try {
|
||||
selectedSlot.value = await adminApi.feedSlot(id)
|
||||
} catch {
|
||||
selectedSlot.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadSlots()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>运营位 CMS</h1>
|
||||
<p class="muted">Banner / FeedSlot 只读 · 非 UGC · 本切片不可发布</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>横幅</h2>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>位置</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in items" :key="b.id">
|
||||
<td><code>{{ b.code }}</code></td>
|
||||
<td>{{ b.title }}</td>
|
||||
<td>{{ b.placement }}</td>
|
||||
<td>{{ b.active ? '启用' : '停用' }}</td>
|
||||
<td><button class="btn" type="button" @click="openBanner(b.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>横幅详情</h2>
|
||||
<template v-if="selected">
|
||||
<p>{{ selected.title }} · {{ selected.placement }}</p>
|
||||
<p class="muted">链接 {{ selected.link_path || '—' }} · 排序 {{ selected.sort_order }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧横幅</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="slotsLoading" class="muted gap">栏目位加载中…</p>
|
||||
<p v-else-if="slotsError" class="err gap">{{ slotsError }}</p>
|
||||
<div v-else class="layout gap">
|
||||
<div class="card">
|
||||
<h2>栏目位 FeedSlot</h2>
|
||||
<p v-if="!slots.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>slot_key</th><th>位置</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in slots" :key="s.id">
|
||||
<td><code>{{ s.code }}</code></td>
|
||||
<td>{{ s.title }}</td>
|
||||
<td><code>{{ s.slot_key }}</code></td>
|
||||
<td>{{ s.placement }}</td>
|
||||
<td><button class="btn" type="button" @click="openSlot(s.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>栏目位详情</h2>
|
||||
<template v-if="selectedSlot">
|
||||
<p>{{ selectedSlot.title }} · {{ selectedSlot.placement }}</p>
|
||||
<p class="muted">slot_key {{ selectedSlot.slot_key }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧栏目位</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.gap { margin-top: 1.5rem; }
|
||||
code { font-size: 0.8rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Row = { id: string; code?: string; slug?: string; title?: string; status?: string }
|
||||
|
||||
const catalogs = [
|
||||
{ key: 'publications', label: '定时发布', load: () => adminApi.publications() },
|
||||
{ key: 'knowledgeChunks', label: '知识块', load: () => adminApi.knowledgeChunks() },
|
||||
{ key: 'tools', label: '工具定义', load: () => adminApi.tools() },
|
||||
{ key: 'blockPolicies', label: '拦截策略', load: () => adminApi.blockPolicies() },
|
||||
{ key: 'cases', label: '审核案', load: () => adminApi.cases() },
|
||||
{ key: 'events', label: '危机事件', load: () => adminApi.events() },
|
||||
{ key: 'interventions', label: '干预结果', load: () => adminApi.interventions() },
|
||||
{ key: 'handoffs', label: '转接案', load: () => adminApi.handoffs() },
|
||||
{ key: 'privacy', label: '隐私请求', load: () => adminApi.requests() },
|
||||
{ key: 'star', label: '星座配置', load: () => adminApi.starConfigs() },
|
||||
{ key: 'rhythm', label: '节律配置', load: () => adminApi.rhythmConfigs() },
|
||||
{ key: 'decks', label: '意象牌组', load: () => adminApi.imageCardDecks() },
|
||||
{ key: 'templates', label: '报告模板', load: () => adminApi.reportTemplates() },
|
||||
{ key: 'funnels', label: '漏斗定义', load: () => adminApi.funnelDefinitions() },
|
||||
{ key: 'scales', label: '量表定义', load: () => adminApi.exploreScales() },
|
||||
] as const
|
||||
|
||||
const active = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Row[]>([])
|
||||
|
||||
async function load(idx = active.value) {
|
||||
active.value = idx
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = (await catalogs[idx].load()) as { items?: Row[] }
|
||||
items.value = (res.items || []) as Row[]
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load(0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>目录只读仓</h1>
|
||||
<p class="muted">ECR-026…040 运营只读目录聚合(不可写发布)</p>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(c, i) in catalogs"
|
||||
:key="c.key"
|
||||
class="btn"
|
||||
:class="{ on: i === active }"
|
||||
type="button"
|
||||
@click="load(i)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="card">
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>标识</th><th>标题</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="it in items" :key="it.id">
|
||||
<td><code>{{ it.code || it.slug || it.id.slice(0, 8) }}</code></td>
|
||||
<td>{{ it.title || '—' }}</td>
|
||||
<td>{{ it.status || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 1rem 0; }
|
||||
.btn.on { background: #ffe4e0; color: var(--accent); font-weight: 700; }
|
||||
code { font-size: 0.8rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Policy = Awaited<ReturnType<typeof adminApi.crisisPolicies>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Policy[]>([])
|
||||
const sample = ref('真的不想活了,怎么办')
|
||||
const matches = ref<
|
||||
Array<{ code: string; title: string; severity: string; action: string; helpline_text?: string }>
|
||||
>([])
|
||||
const evalMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.crisisPolicies()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runEval() {
|
||||
evalMsg.value = ''
|
||||
try {
|
||||
const res = await adminApi.evaluateCrisis(sample.value)
|
||||
matches.value = res.matches || []
|
||||
evalMsg.value = matches.value.length ? `命中 ${matches.value.length} 条` : '未命中'
|
||||
} catch (e) {
|
||||
evalMsg.value = e instanceof Error ? e.message : '试匹配失败'
|
||||
matches.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>危机策略</h1>
|
||||
<p class="muted">CrisisPolicy 只读 · 试匹配不写 CrisisEvent · 非医疗诊断</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>策略目录</h2>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>严重度</th><th>动作</th><th>模式</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in items" :key="p.id">
|
||||
<td>{{ p.title }} <code>{{ p.code }}</code></td>
|
||||
<td>{{ p.severity }}</td>
|
||||
<td>{{ p.action }}</td>
|
||||
<td>{{ p.pattern }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>试匹配</h2>
|
||||
<textarea v-model="sample" rows="4" />
|
||||
<div class="row">
|
||||
<button class="btn" type="button" @click="runEval">试匹配</button>
|
||||
<span class="muted">{{ evalMsg }}</span>
|
||||
</div>
|
||||
<ul v-if="matches.length" class="hits">
|
||||
<li v-for="m in matches" :key="m.code">
|
||||
<strong>{{ m.title }}</strong> · {{ m.severity }} · {{ m.action }}
|
||||
<p v-if="m.helpline_text" class="help">{{ m.helpline_text }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
}
|
||||
.row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.6rem; }
|
||||
.hits { margin: 0.75rem 0 0; padding-left: 1.1rem; }
|
||||
.help { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.85rem; }
|
||||
code { font-size: 0.75rem; color: var(--muted); margin-left: 0.25rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Plan = {
|
||||
code: string
|
||||
title: string
|
||||
duration_days: number
|
||||
amount_cents: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const msg = ref('')
|
||||
const items = ref<Plan[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.membershipPlans()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(p: Plan) {
|
||||
msg.value = ''
|
||||
try {
|
||||
await adminApi.updateMembershipPlan(p.code, {
|
||||
title: p.title,
|
||||
duration_days: Number(p.duration_days),
|
||||
amount_cents: Number(p.amount_cents),
|
||||
active: p.active,
|
||||
})
|
||||
msg.value = `${p.code} 已保存`
|
||||
await load()
|
||||
} catch (e) {
|
||||
msg.value = e instanceof Error ? e.message : '保存失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>会员套餐</h1>
|
||||
<p class="muted">配置成长会员时长与标价(mock 履约读表)。</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
<div v-for="p in items" :key="p.code" class="card block">
|
||||
<h2>{{ p.code }}</h2>
|
||||
<div class="row">
|
||||
<label>标题 <input v-model="p.title" /></label>
|
||||
<label>天数 <input v-model.number="p.duration_days" type="number" min="1" /></label>
|
||||
<label>标价(分) <input v-model.number="p.amount_cents" type="number" min="0" /></label>
|
||||
<label class="chk"><input v-model="p.active" type="checkbox" /> 启用</label>
|
||||
<button
|
||||
v-if="auth.can('admin.membership.plans.write')"
|
||||
class="btn"
|
||||
type="button"
|
||||
@click="save(p)"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; text-transform: uppercase; }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
|
||||
input { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; min-width: 6rem; }
|
||||
.chk { flex-direction: row; align-items: center; gap: 0.35rem; padding-bottom: 0.4rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const msg = ref('')
|
||||
const label = ref('batch')
|
||||
const plan = ref('month')
|
||||
const qty = ref(5)
|
||||
const batches = ref<Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }>>([])
|
||||
const codes = ref<Array<{ id: string; code: string; status: string }>>([])
|
||||
const activeBatch = ref('')
|
||||
|
||||
async function loadBatches() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.redemptionBatches()
|
||||
batches.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createBatch() {
|
||||
msg.value = ''
|
||||
try {
|
||||
const res = await adminApi.createRedemptionBatch({
|
||||
label: label.value,
|
||||
plan_code: plan.value,
|
||||
quantity: Number(qty.value),
|
||||
})
|
||||
msg.value = `已生成 ${res.codes.length} 个码`
|
||||
activeBatch.value = res.batch.id
|
||||
codes.value = res.codes
|
||||
await loadBatches()
|
||||
} catch (e) {
|
||||
msg.value = e instanceof Error ? e.message : '生成失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function openBatch(id: string) {
|
||||
activeBatch.value = id
|
||||
const res = await adminApi.redemptionCodes(id)
|
||||
codes.value = res.items || []
|
||||
}
|
||||
|
||||
async function disable(id: string) {
|
||||
await adminApi.disableRedemptionCode(id)
|
||||
if (activeBatch.value) await openBatch(activeBatch.value)
|
||||
}
|
||||
|
||||
onMounted(loadBatches)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>兑换码</h1>
|
||||
<p class="muted">批量生成会员兑换码(禁真支付)。</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
|
||||
<div v-if="auth.can('admin.membership.codes.write')" class="card block">
|
||||
<h2>生成批次</h2>
|
||||
<div class="row">
|
||||
<label>标签 <input v-model="label" /></label>
|
||||
<label>套餐
|
||||
<select v-model="plan">
|
||||
<option value="month">month</option>
|
||||
<option value="quarter">quarter</option>
|
||||
<option value="year">year</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>数量 <input v-model.number="qty" type="number" min="1" max="100" /></label>
|
||||
<button class="btn" type="button" @click="createBatch">生成</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>批次</h2>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<ul v-else>
|
||||
<li v-for="b in batches" :key="b.id">
|
||||
<button class="link" type="button" @click="openBatch(b.id)">
|
||||
{{ b.label }} · {{ b.plan_code }} × {{ b.quantity }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="codes.length" class="card block">
|
||||
<h2>码列表</h2>
|
||||
<table>
|
||||
<thead><tr><th>码</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in codes" :key="c.id">
|
||||
<td><code>{{ c.code }}</code></td>
|
||||
<td>{{ c.status }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="c.status === 'unused' && auth.can('admin.membership.codes.write')"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="disable(c.id)"
|
||||
>
|
||||
作废
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
|
||||
input, select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
|
||||
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0.2rem 0; }
|
||||
ul { margin: 0; padding-left: 1rem; }
|
||||
code { font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Rule = Awaited<ReturnType<typeof adminApi.filterRules>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Rule[]>([])
|
||||
const sample = ref('我不想活了,求帮助')
|
||||
const matches = ref<Array<{ code: string; title: string; category: string; action: string }>>([])
|
||||
const evalMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.filterRules()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runEval() {
|
||||
evalMsg.value = ''
|
||||
try {
|
||||
const res = await adminApi.evaluateContent(sample.value)
|
||||
matches.value = res.matches || []
|
||||
evalMsg.value = matches.value.length ? `命中 ${matches.value.length} 条` : '未命中'
|
||||
} catch (e) {
|
||||
evalMsg.value = e instanceof Error ? e.message : '试匹配失败'
|
||||
matches.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>内容安全</h1>
|
||||
<p class="muted">FilterRule 只读 · 试匹配不写审核工单</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>过滤规则</h2>
|
||||
<p v-if="!items.length" class="muted">暂无规则</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>分类</th><th>动作</th><th>模式</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in items" :key="r.id">
|
||||
<td>{{ r.title }} <code>{{ r.code }}</code></td>
|
||||
<td>{{ r.category }}</td>
|
||||
<td>{{ r.action }}</td>
|
||||
<td>{{ r.pattern }}</td>
|
||||
<td>{{ r.active ? '启用' : '停用' }}{{ r.system ? ' · 系统' : '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>试匹配</h2>
|
||||
<textarea v-model="sample" rows="4" />
|
||||
<div class="row">
|
||||
<button class="btn" type="button" @click="runEval">试匹配</button>
|
||||
<span class="muted">{{ evalMsg }}</span>
|
||||
</div>
|
||||
<ul v-if="matches.length" class="hits">
|
||||
<li v-for="m in matches" :key="m.code">
|
||||
{{ m.title }} · {{ m.category }} · <strong>{{ m.action }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
}
|
||||
.row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.6rem; }
|
||||
.hits { margin: 0.75rem 0 0; padding-left: 1.1rem; }
|
||||
code { font-size: 0.75rem; color: var(--muted); margin-left: 0.25rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,25 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { adminApi, type UserDetail } from '@/api/client'
|
||||
import UserEntitlementPanel, { type Entitlement } from '@/components/UserEntitlementPanel.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Insight = Awaited<ReturnType<typeof adminApi.userInsight>>
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detail = ref<UserDetail | null>(null)
|
||||
const tab = ref<'base' | 'insight' | 'entitlement'>('base')
|
||||
const insight = ref<Insight | null>(null)
|
||||
const insightErr = ref('')
|
||||
const insightLoading = ref(false)
|
||||
const entitlement = ref<Entitlement | null>(null)
|
||||
const entitlementErr = ref('')
|
||||
const entitlementLoading = ref(false)
|
||||
const plan = ref('month')
|
||||
const askDelta = ref(10)
|
||||
const grantMsg = ref('')
|
||||
const askMsg = ref('')
|
||||
const banMsg = ref('')
|
||||
const nextStatus = ref('banned')
|
||||
const statusReason = ref('')
|
||||
const statusMsg = ref('')
|
||||
const transitions = ref<
|
||||
Array<{
|
||||
from_status: string
|
||||
to_status: string
|
||||
reason: string
|
||||
created_at: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
const canWriteStatus = computed(() => auth.can('admin.users.status.write'))
|
||||
const canGrantMembership = computed(() => auth.can('admin.users.membership.grant'))
|
||||
const canGrantAskQuota = computed(() => auth.can('admin.users.ask_quota.grant'))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
detail.value = await adminApi.user(String(route.params.id))
|
||||
const id = String(route.params.id)
|
||||
detail.value = await adminApi.user(id)
|
||||
try {
|
||||
const tr = await adminApi.statusTransitions(id)
|
||||
transitions.value = tr.items || []
|
||||
} catch {
|
||||
transitions.value = []
|
||||
}
|
||||
if (tab.value === 'insight') await loadInsight()
|
||||
if (tab.value === 'entitlement') await loadEntitlement()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
@@ -27,6 +60,37 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInsight() {
|
||||
insightLoading.value = true
|
||||
insightErr.value = ''
|
||||
try {
|
||||
insight.value = await adminApi.userInsight(String(route.params.id))
|
||||
} catch (e) {
|
||||
insightErr.value = e instanceof Error ? e.message : '洞察加载失败'
|
||||
insight.value = null
|
||||
} finally {
|
||||
insightLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntitlement() {
|
||||
entitlementLoading.value = true
|
||||
entitlementErr.value = ''
|
||||
try {
|
||||
entitlement.value = await adminApi.userEntitlements(String(route.params.id))
|
||||
} catch (e) {
|
||||
entitlementErr.value = e instanceof Error ? e.message : '权益加载失败'
|
||||
entitlement.value = null
|
||||
} finally {
|
||||
entitlementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(tab, (v) => {
|
||||
if (v === 'insight' && !insight.value && !insightLoading.value) void loadInsight()
|
||||
if (v === 'entitlement' && !entitlement.value && !entitlementLoading.value) void loadEntitlement()
|
||||
})
|
||||
|
||||
async function grant() {
|
||||
grantMsg.value = ''
|
||||
try {
|
||||
@@ -49,19 +113,31 @@ async function grantAsk() {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus() {
|
||||
statusMsg.value = ''
|
||||
try {
|
||||
await adminApi.setUserStatus(String(route.params.id), nextStatus.value, statusReason.value)
|
||||
statusMsg.value = '状态已更新'
|
||||
statusReason.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
statusMsg.value = e instanceof Error ? e.message : '状态更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBan() {
|
||||
banMsg.value = ''
|
||||
statusMsg.value = ''
|
||||
try {
|
||||
if (detail.value?.status === 'banned') {
|
||||
await adminApi.unbanUser(String(route.params.id))
|
||||
banMsg.value = '已解封'
|
||||
statusMsg.value = '已解封'
|
||||
} else {
|
||||
await adminApi.banUser(String(route.params.id))
|
||||
banMsg.value = '已封禁'
|
||||
statusMsg.value = '已封禁'
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
banMsg.value = e instanceof Error ? e.message : '操作失败'
|
||||
statusMsg.value = e instanceof Error ? e.message : '操作失败'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,107 +169,199 @@ onMounted(load)
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<template v-else-if="detail">
|
||||
<div class="card block">
|
||||
<h2>账号</h2>
|
||||
<div class="kv">
|
||||
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
|
||||
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ detail.status }}</strong></div>
|
||||
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
|
||||
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
|
||||
<div class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'base' }" @click="tab = 'base'">基础</button>
|
||||
<button type="button" :class="{ on: tab === 'insight' }" @click="tab = 'insight'">洞察</button>
|
||||
<button type="button" :class="{ on: tab === 'entitlement' }" @click="tab = 'entitlement'">权益</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'base'">
|
||||
<div class="card block">
|
||||
<h2>账号</h2>
|
||||
<div class="kv">
|
||||
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
|
||||
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ detail.status }}</strong></div>
|
||||
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
|
||||
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
|
||||
</div>
|
||||
<div v-if="canWriteStatus" class="grant">
|
||||
<select v-model="nextStatus">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="banned">banned</option>
|
||||
<option value="suspended">suspended</option>
|
||||
</select>
|
||||
<input v-model="statusReason" class="reason" type="text" placeholder="原因(必填)" />
|
||||
<button class="btn" type="button" @click="changeStatus">变更状态</button>
|
||||
<button class="btn ghost" type="button" @click="toggleBan">
|
||||
{{ detail.status === 'banned' ? '快捷解封' : '快捷封禁' }}
|
||||
</button>
|
||||
<span v-if="statusMsg" class="muted">{{ statusMsg }}</span>
|
||||
</div>
|
||||
<div v-if="transitions.length" class="trans">
|
||||
<h3>状态迁移</h3>
|
||||
<ul>
|
||||
<li v-for="(t, i) in transitions" :key="i">
|
||||
{{ t.from_status }} → {{ t.to_status }} · {{ t.reason || '—' }} · {{ fmtTime(t.created_at) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grant">
|
||||
<button class="btn" type="button" @click="toggleBan">
|
||||
{{ detail.status === 'banned' ? '解封' : '封禁' }}
|
||||
</button>
|
||||
<span v-if="banMsg" class="muted">{{ banMsg }}</span>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="detail.membership">
|
||||
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||
{{ detail.membership.plan || '—' }} ·
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div v-if="canGrantMembership" class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月卡</option>
|
||||
<option value="quarter">季卡</option>
|
||||
<option value="year">年卡</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||
</div>
|
||||
<p v-else class="muted">无会员授予权限</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="detail.membership">
|
||||
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||
{{ detail.membership.plan || '—' }} ·
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月卡</option>
|
||||
<option value="quarter">季卡</option>
|
||||
<option value="year">年卡</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||
<div class="card block">
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div v-if="canGrantAskQuota" class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
<option :value="100">+100</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grantAsk">增加额度</button>
|
||||
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
|
||||
</div>
|
||||
<p v-else class="muted">无问答额度授予权限</p>
|
||||
</div>
|
||||
<p v-else class="muted">仅超级管理员可授予会员</p>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
<option :value="100">+100</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grantAsk">增加额度</button>
|
||||
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
|
||||
<div class="card block">
|
||||
<h2>档案({{ detail.profiles?.length || 0 }})</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in detail.profiles" :key="p.id">
|
||||
<td>{{ p.display_name || '未命名' }}</td>
|
||||
<td>{{ p.relation }}</td>
|
||||
<td>{{ p.birth_date || '—' }}</td>
|
||||
<td><code>{{ p.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>档案({{ detail.profiles?.length || 0 }})</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in detail.profiles" :key="p.id">
|
||||
<td>{{ p.display_name || '未命名' }}</td>
|
||||
<td>{{ p.relation }}</td>
|
||||
<td>{{ p.birth_date || '—' }}</td>
|
||||
<td><code>{{ p.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>成长报告(近 {{ detail.reports?.length || 0 }})</h2>
|
||||
<p v-if="!detail.reports?.length" class="muted">无报告</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in detail.reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
<td><code>{{ r.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长报告(近 {{ detail.reports?.length || 0 }})</h2>
|
||||
<p v-if="!detail.reports?.length" class="muted">无报告</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in detail.reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
<td><code>{{ r.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>近订单</h2>
|
||||
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ fmtTime(o.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card block">
|
||||
<h2>近订单</h2>
|
||||
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ fmtTime(o.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template v-else-if="tab === 'insight'">
|
||||
<p v-if="insightLoading" class="muted">加载洞察…</p>
|
||||
<p v-else-if="insightErr" class="err">{{ insightErr }}</p>
|
||||
<template v-else-if="insight">
|
||||
<div class="card block">
|
||||
<h2>概览</h2>
|
||||
<div class="kv">
|
||||
<div><span>档案数</span><strong>{{ insight.profiles_count }}</strong></div>
|
||||
<div><span>问答线程</span><strong>{{ insight.behavior.ask_thread_count }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>心理标签(派生)</h2>
|
||||
<p v-if="!insight.tags?.length" class="muted">暂无(无成长报告类型)</p>
|
||||
<ul v-else class="tags">
|
||||
<li v-for="t in insight.tags" :key="t.code">{{ t.label }} <code>{{ t.code }}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>报告类型</h2>
|
||||
<p v-if="!insight.reports_by_type?.length" class="muted">无</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in insight.reports_by_type" :key="c.type">
|
||||
<td>{{ typeLabel[c.type] || c.type }}</td>
|
||||
<td>{{ c.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>近报告</h2>
|
||||
<p v-if="!insight.recent_reports?.length" class="muted">无</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in insight.recent_reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>行为快照</h2>
|
||||
<p v-if="!insight.behavior.events?.length" class="muted">暂无埋点(仍为正常)</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>事件</th><th>页面</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(e, i) in insight.behavior.events" :key="i">
|
||||
<td>{{ e.name }}</td>
|
||||
<td>{{ e.page_path || '—' }}</td>
|
||||
<td>{{ fmtTime(e.received_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'entitlement'">
|
||||
<p v-if="entitlementLoading" class="muted">加载权益…</p>
|
||||
<p v-else-if="entitlementErr" class="err">{{ entitlementErr }}</p>
|
||||
<UserEntitlementPanel v-else-if="entitlement" :data="entitlement" />
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -203,6 +371,17 @@ onMounted(load)
|
||||
.crumb a { color: var(--accent); }
|
||||
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0.75rem 0 0.35rem; font-size: 0.95rem; }
|
||||
.tabs { display: flex; gap: 0.35rem; margin-bottom: 1rem; }
|
||||
.tabs button {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tabs button.on { color: var(--text); border-color: var(--accent); }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.kv {
|
||||
display: grid;
|
||||
@@ -213,5 +392,19 @@ h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.kv .wide { grid-column: 1 / -1; }
|
||||
.kv code { font-size: 0.82rem; word-break: break-all; }
|
||||
.grant { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.75rem; }
|
||||
.grant select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
|
||||
.grant select, .grant .reason {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
.grant .reason { min-width: 12rem; }
|
||||
.trans ul { margin: 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; }
|
||||
.tags { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.tags li {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tags code { margin-left: 0.35rem; font-size: 0.75rem; color: var(--muted); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user