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:
jackyu66git
2026-08-13 01:45:53 +08:00
co-authored by Cursor
593 changed files with 21918 additions and 328 deletions
+32 -2
View File
@@ -21,7 +21,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
await ok({
token: 'adm_e2e_token',
expires_at: new Date(Date.now() + 3600_000).toISOString(),
admin: { id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' },
admin: {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
username: 'admin',
role: 'super_admin',
permissions: [
'admin.users.read',
'admin.users.membership.grant',
'admin.users.ask_quota.grant',
'admin.orders.read',
'admin.audit.read',
'admin.analytics.read',
'admin.content.write',
'admin.roles.read',
'admin.roles.write',
],
},
})
return
}
@@ -55,7 +70,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
return
}
if (url.endsWith('/me') || url.includes('/me?')) {
await ok({ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' })
await ok({
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
username: 'admin',
role: 'super_admin',
permissions: [
'admin.users.read',
'admin.users.membership.grant',
'admin.users.ask_quota.grant',
'admin.orders.read',
'admin.audit.read',
'admin.analytics.read',
'admin.content.write',
'admin.roles.read',
'admin.roles.write',
],
})
return
}
if (url.includes(`/users/${userId}/ask-quota/grant`) && method === 'POST') {
+385 -2
View File
@@ -31,7 +31,17 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const env = (await res.json()) as ApiEnvelope<T>
const text = await res.text()
let env: ApiEnvelope<T>
try {
env = JSON.parse(text) as ApiEnvelope<T>
} catch {
throw new Error(
res.status === 404
? `接口不存在或后端未更新(${path}`
: `响应不是 JSONHTTP ${res.status}`,
)
}
if (!res.ok || env.code !== 0) {
throw new Error(env.message || `HTTP ${res.status}`)
}
@@ -96,7 +106,12 @@ export type UserDetail = {
export type AdminRole = 'super' | 'ops'
export type AdminMe = { id: string; username: string; role: AdminRole }
export type AdminMe = {
id: string
username: string
role?: string
permissions?: string[]
}
export type PushJob = {
id: string
@@ -125,12 +140,63 @@ export const adminApi = {
}),
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
me: () => request<AdminMe>('GET', '/me'),
roles: () =>
request<{ items: Array<{ id: string; name: string; system: boolean }> }>('GET', '/roles'),
role: (id: string) =>
request<{ id: string; name: string; system: boolean; permissions: string[] }>('GET', `/roles/${id}`),
stats: () => request<DashboardStats>('GET', '/stats'),
users: (q = '') =>
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`),
unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`),
setUserStatus: (id: string, status: string, reason: string) =>
request<UserDetail>('POST', `/users/${id}/status`, { status, reason }),
statusTransitions: (id: string) =>
request<{
items: Array<{
id: string
from_status: string
to_status: string
admin_id: string
reason: string
created_at: string
}>
}>('GET', `/users/${id}/status-transitions`),
userInsight: (id: string) =>
request<{
user_id: string
profiles_count: number
reports_by_type: Array<{ type: string; count: number }>
recent_reports: Array<{ id: string; type: string; created_at: string }>
tags: Array<{ code: string; label: string }>
behavior: {
events: Array<{ name: string; page_path?: string; received_at: string }>
ask_thread_count: number
}
}>('GET', `/users/${id}/insight`),
userEntitlements: (id: string) =>
request<{
user_id: string
membership: {
plan?: string
status: string
expires_at?: string
ask_quota_left?: number
active: boolean
}
ask_paid_quota_left: number
flags: {
report_detail_via_membership: boolean
deep_access_count: number
}
deep_accesses: Array<{
id: string
report_id: string
report_type?: string
created_at: string
}>
}>('GET', `/users/${id}/entitlements`),
grant: (id: string, plan: string) =>
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
grantAskQuota: (id: string, delta: number) =>
@@ -145,6 +211,323 @@ export const adminApi = {
request<PushJob>('POST', '/push-jobs', body),
patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) =>
request<PushJob>('PATCH', `/push-jobs/${id}`, body),
membershipPlans: () =>
request<{
items: Array<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
updated_at: string
}>
}>('GET', '/membership-plans'),
updateMembershipPlan: (
code: string,
body: { title: string; duration_days: number; amount_cents: number; active: boolean },
) =>
request<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
}>('PUT', `/membership-plans/${code}`, body),
createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) =>
request<{
batch: { id: string; label: string; plan_code: string; quantity: number }
codes: Array<{ id: string; code: string; status: string }>
}>('POST', '/redemption-batches', body),
redemptionBatches: () =>
request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>(
'GET',
'/redemption-batches',
),
redemptionCodes: (batchId: string) =>
request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>(
'GET',
`/redemption-batches/${batchId}/codes`,
),
disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`),
askThreads: (userId = '') => {
const q = userId ? `?user_id=${encodeURIComponent(userId)}` : ''
return request<{
items: Array<{
id: string
user_id: string
profile_id: string
scene?: string
message_count: number
created_at: string
updated_at: string
}>
}>('GET', `/ask/threads${q}`)
},
askThread: (id: string) =>
request<{
id: string
user_id: string
profile_id: string
scene?: string
message_count: number
created_at: string
updated_at: string
messages: Array<{ id: string; role: string; content: string; created_at: string }>
}>('GET', `/ask/threads/${id}`),
filterRules: () =>
request<{
items: Array<{
id: string
code: string
title: string
category: string
pattern: string
action: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/content-safety/filter-rules'),
filterRule: (id: string) =>
request<{
id: string
code: string
title: string
category: string
pattern: string
action: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/content-safety/filter-rules/${id}`),
evaluateContent: (text: string) =>
request<{ matches: Array<{ code: string; title: string; category: string; action: string }> }>(
'POST',
'/content-safety/evaluate',
{ text },
),
askFeedback: () =>
request<{
items: Array<{
id: string
thread_id: string
message_id?: string
source: string
rating: number
tag?: string
note?: string
created_at: string
}>
}>('GET', '/ask/feedback'),
createAskFeedback: (threadId: string, body: { rating: number; tag?: string; note?: string; message_id?: string }) =>
request<{
id: string
thread_id: string
rating: number
source: string
}>('POST', `/ask/threads/${threadId}/feedback`, body),
systemPrompts: () =>
request<{
items: Array<{
id: string
code: string
title: string
scene?: string
body: string
version: number
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/ai/system-prompts'),
systemPrompt: (id: string) =>
request<{
id: string
code: string
title: string
scene?: string
body: string
version: number
active: boolean
system: boolean
updated_at: string
}>('GET', `/ai/system-prompts/${id}`),
knowledgeSources: () =>
request<{
items: Array<{
id: string
code: string
title: string
description?: string
source_kind: string
version: number
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/ai/knowledge-sources'),
knowledgeSource: (id: string) =>
request<{
id: string
code: string
title: string
description?: string
source_kind: string
version: number
active: boolean
system: boolean
updated_at: string
}>('GET', `/ai/knowledge-sources/${id}`),
crisisPolicies: () =>
request<{
items: Array<{
id: string
code: string
title: string
severity: string
pattern: string
action: string
helpline_text?: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/crisis/policies'),
crisisPolicy: (id: string) =>
request<{
id: string
code: string
title: string
severity: string
pattern: string
action: string
helpline_text?: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/crisis/policies/${id}`),
evaluateCrisis: (text: string) =>
request<{
matches: Array<{
code: string
title: string
severity: string
action: string
helpline_text?: string
}>
}>('POST', '/crisis/evaluate', { text }),
banners: () =>
request<{
items: Array<{
id: string
code: string
title: string
placement: string
image_url?: string
link_path?: string
sort_order: number
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/cms/banners'),
banner: (id: string) =>
request<{
id: string
code: string
title: string
placement: string
image_url?: string
link_path?: string
sort_order: number
active: boolean
system: boolean
updated_at: string
}>('GET', `/cms/banners/${id}`),
feedSlots: () =>
request<{
items: Array<{
id: string
code: string
title: string
slot_key: string
placement: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/cms/feed-slots'),
feedSlot: (id: string) =>
request<{
id: string
code: string
title: string
slot_key: string
placement: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/cms/feed-slots/${id}`),
publications: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/cms/publications'),
publication: (id: string) =>
request<Record<string, unknown>>('GET', `/cms/publications/${id}`),
knowledgeChunks: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/ai/knowledge-chunks'),
knowledgeChunk: (id: string) =>
request<Record<string, unknown>>('GET', `/ai/knowledge-chunks/${id}`),
tools: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/ai/tools'),
tool: (id: string) =>
request<Record<string, unknown>>('GET', `/ai/tools/${id}`),
blockPolicies: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/block-policies'),
blockPolicie: (id: string) =>
request<Record<string, unknown>>('GET', `/content-safety/block-policies/${id}`),
cases: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/cases'),
case: (id: string) =>
request<Record<string, unknown>>('GET', `/content-safety/cases/${id}`),
events: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/crisis/events'),
event: (id: string) =>
request<Record<string, unknown>>('GET', `/crisis/events/${id}`),
interventions: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/crisis/interventions'),
intervention: (id: string) =>
request<Record<string, unknown>>('GET', `/crisis/interventions/${id}`),
handoffs: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/ask/handoffs'),
handoff: (id: string) =>
request<Record<string, unknown>>('GET', `/ask/handoffs/${id}`),
requests: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/privacy/requests'),
privacyRequest: (id: string) =>
request<Record<string, unknown>>('GET', `/privacy/requests/${id}`),
starConfigs: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/star-configs'),
starConfig: (id: string) =>
request<Record<string, unknown>>('GET', `/explore/star-configs/${id}`),
rhythmConfigs: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/rhythm-configs'),
rhythmConfig: (id: string) =>
request<Record<string, unknown>>('GET', `/explore/rhythm-configs/${id}`),
imageCardDecks: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/image-card-decks'),
imageCardDeck: (id: string) =>
request<Record<string, unknown>>('GET', `/explore/image-card-decks/${id}`),
reportTemplates: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/growth/report-templates'),
reportTemplate: (id: string) =>
request<Record<string, unknown>>('GET', `/growth/report-templates/${id}`),
funnelDefinitions: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/analytics/funnel-definitions'),
funnelDefinition: (id: string) =>
request<Record<string, unknown>>('GET', `/analytics/funnel-definitions/${id}`),
exploreScales: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/scales'),
exploreScale: (id: string) =>
request<Record<string, unknown>>('GET', `/explore/scales/${id}`),
orders: (params?: {
status?: string
kind?: string
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { adminApi } from '@/api/client'
export type Entitlement = Awaited<ReturnType<typeof adminApi.userEntitlements>>
defineProps<{
data: Entitlement
}>()
const typeLabel: Record<string, string> = {
portrait: '愈心解码',
star: '星座',
rhythm: '节律',
relation: '关系',
synastry: '合盘',
image_card: '意象卡',
}
function fmtTime(iso?: string | null) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN')
} catch {
return iso
}
}
</script>
<template>
<div>
<div class="card block">
<h2>权益概览</h2>
<div class="kv">
<div>
<span>报告 detail会员</span>
<strong>{{ data.flags.report_detail_via_membership ? '是' : '否' }}</strong>
</div>
<div>
<span>DeepAccess 份数</span>
<strong>{{ data.flags.deep_access_count }}</strong>
</div>
<div>
<span>已购问答余量</span>
<strong>{{ data.ask_paid_quota_left }}</strong>
</div>
</div>
</div>
<div class="card block">
<h2>成长会员</h2>
<p v-if="data.membership">
{{ data.membership.active ? '有效' : '无效' }} ·
{{ data.membership.plan || '' }} ·
会员问答 {{ data.membership.ask_quota_left ?? 0 }} ·
到期 {{ fmtTime(data.membership.expires_at) }}
</p>
<p v-else class="muted">无会员记录</p>
</div>
<div class="card block">
<h2>深度版DeepAccess</h2>
<p v-if="!data.deep_accesses?.length" class="muted">无单份深度版</p>
<table v-else>
<thead>
<tr><th>类型</th><th>报告</th><th>时间</th></tr>
</thead>
<tbody>
<tr v-for="d in data.deep_accesses" :key="d.id">
<td>{{ typeLabel[d.report_type || ''] || d.report_type || '—' }}</td>
<td><code>{{ d.report_id.slice(0, 8) }}</code></td>
<td>{{ fmtTime(d.created_at) }}</td>
</tr>
</tbody>
</table>
<p class="hint">
<RouterLink :to="`/users/${data.user_id}`">返回基础信息可授予会员 / 问答额度</RouterLink>
</p>
</div>
</div>
</template>
<style scoped>
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.block { margin-bottom: 1rem; }
.kv {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.kv span { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.15rem; }
.hint { margin-top: 0.75rem; font-size: 0.85rem; }
.hint a { color: var(--accent); }
</style>
+11 -1
View File
@@ -28,6 +28,14 @@ async function onLogout() {
<RouterLink to="/analytics">数据</RouterLink>
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/codes">兑换码</RouterLink>
<RouterLink to="/ask">问答</RouterLink>
<RouterLink to="/safety">安全</RouterLink>
<RouterLink to="/ai">AI</RouterLink>
<RouterLink to="/crisis">危机</RouterLink>
<RouterLink to="/cms">CMS</RouterLink>
<RouterLink to="/catalogs">目录仓</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink v-if="auth.isSuper" to="/pricing">定价</RouterLink>
<RouterLink to="/push">推送</RouterLink>
@@ -35,7 +43,8 @@ async function onLogout() {
<RouterLink to="/audit">审计</RouterLink>
</nav>
<div class="foot">
<span class="muted">{{ auth.username || '管理员' }} · {{ auth.role }}</span>
<span class="muted">{{ auth.username || '管理员' }}</span>
<span v-if="auth.role" class="role">{{ auth.role }} · {{ auth.permissions.length }} </span>
<button class="btn ghost" type="button" @click="onLogout">退出</button>
</div>
</aside>
@@ -75,6 +84,7 @@ nav a.router-link-active {
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.08);
}
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.role { font-size: 0.75rem; color: var(--muted); }
.main { padding: 1.5rem 1.75rem; }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; }
+173
View File
@@ -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>
+194
View File
@@ -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>
+141
View File
@@ -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-026040 运营只读目录聚合不可写发布</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>
+103
View File
@@ -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>
+131
View File
@@ -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>
+100
View File
@@ -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>
+293 -100
View File
@@ -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>
+8
View File
@@ -16,7 +16,15 @@ const router = createRouter({
{ path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { superOnly: true } },
{ path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') },
{ path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') },
{ path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.vue') },
{ path: 'crisis', name: 'crisis', component: () => import('@/pages/CrisisPage.vue') },
{ path: 'cms', name: 'cms', component: () => import('@/pages/CMSPage.vue') },
{ path: 'catalogs', name: 'catalogs', component: () => import('@/pages/CatalogHubPage.vue') },
{ path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') },
{ path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
+24 -9
View File
@@ -1,28 +1,38 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { adminApi, getToken, setToken, type AdminRole } from '@/api/client'
import { adminApi, getToken, setToken, type AdminMe } from '@/api/client'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(getToken())
const username = ref<string>('')
const role = ref<AdminRole>('ops')
const role = ref<string>('')
const permissions = ref<string[]>([])
const isSuper = computed(() => role.value === 'super')
const isSuper = computed(
() =>
role.value === 'super' ||
role.value === 'super_admin' ||
permissions.value.includes('admin.roles.write'),
)
function applyMe(me: AdminMe) {
username.value = me.username
role.value = me.role || ''
permissions.value = me.permissions ?? []
}
async function login(user: string, password: string) {
const res = await adminApi.login(user, password)
setToken(res.token)
token.value = res.token
username.value = res.admin.username
role.value = res.admin.role || 'super'
applyMe(res.admin)
}
async function hydrate() {
if (!token.value) return false
try {
const me = await adminApi.me()
username.value = me.username
role.value = me.role || 'super'
applyMe(me)
return true
} catch {
setToken(null)
@@ -40,8 +50,13 @@ export const useAuthStore = defineStore('auth', () => {
setToken(null)
token.value = null
username.value = ''
role.value = 'ops'
role.value = ''
permissions.value = []
}
return { token, username, role, isSuper, login, logout, hydrate }
function can(code: string) {
return isSuper.value || permissions.value.includes(code)
}
return { token, username, role, permissions, isSuper, can, login, logout, hydrate }
})