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
+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>