feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
/** Thin admin API client → /api/v1/admin (proxied to Go). */
|
||||
|
||||
export type ApiEnvelope<T> = { code: number; message: string; data?: T }
|
||||
|
||||
const TOKEN_KEY = 'yuxingu_admin_token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
const token = getToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const res = await fetch(`/api/v1/admin${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const env = (await res.json()) as ApiEnvelope<T>
|
||||
if (!res.ok || env.code !== 0) {
|
||||
throw new Error(env.message || `HTTP ${res.status}`)
|
||||
}
|
||||
return env.data as T
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
|
||||
me: () => request<{ id: string; username: string }>('GET', '/me'),
|
||||
users: (q = '') =>
|
||||
request<{ items: Array<{ id: string; status: string; created_at: string }> }>(
|
||||
'GET',
|
||||
`/users?q=${encodeURIComponent(q)}`,
|
||||
),
|
||||
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
|
||||
grant: (id: string, plan: string) =>
|
||||
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
|
||||
orders: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
user_id: string
|
||||
kind: string
|
||||
plan?: string
|
||||
amount_cents: number
|
||||
status: string
|
||||
created_at: string
|
||||
}>
|
||||
}>('GET', '/orders'),
|
||||
audit: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
admin_id: string
|
||||
action: string
|
||||
target_type: string
|
||||
target_id: string
|
||||
meta: unknown
|
||||
created_at: string
|
||||
}>
|
||||
}>('GET', '/audit-logs'),
|
||||
}
|
||||
|
||||
export type UserDetail = {
|
||||
id: string
|
||||
status: string
|
||||
created_at: string
|
||||
profiles: Array<{ id: string; relation: string; display_name: string }>
|
||||
membership: {
|
||||
active: boolean
|
||||
plan?: string
|
||||
status: string
|
||||
expires_at?: string
|
||||
ask_quota_left?: number
|
||||
}
|
||||
recent_orders: Array<{
|
||||
id: string
|
||||
kind: string
|
||||
plan?: string
|
||||
status: string
|
||||
amount_cents: number
|
||||
created_at: string
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
void auth.hydrate()
|
||||
})
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
await router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<aside class="nav">
|
||||
<div class="brand">愈心谷 · 运营</div>
|
||||
<nav>
|
||||
<RouterLink to="/">用户</RouterLink>
|
||||
<RouterLink to="/orders">订单</RouterLink>
|
||||
<RouterLink to="/audit">审计</RouterLink>
|
||||
</nav>
|
||||
<div class="foot">
|
||||
<span class="muted">{{ auth.username || '管理员' }}</span>
|
||||
<button class="btn ghost" type="button" @click="onLogout">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
|
||||
.nav {
|
||||
padding: 1.25rem 1rem; border-right: 1px solid var(--line);
|
||||
background: rgba(255, 253, 249, 0.85); display: flex; flex-direction: column; gap: 1.25rem;
|
||||
}
|
||||
.brand { font-weight: 700; letter-spacing: 0.02em; }
|
||||
nav { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
nav a { padding: 0.45rem 0.6rem; border-radius: 8px; color: var(--muted); }
|
||||
nav a.router-link-active { background: #f5ebe6; color: var(--accent); font-weight: 600; }
|
||||
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.main { padding: 1.5rem 1.75rem; }
|
||||
@media (max-width: 800px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.nav { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<
|
||||
Array<{
|
||||
id: string
|
||||
admin_id: string
|
||||
action: string
|
||||
target_type: string
|
||||
target_id: string
|
||||
meta: unknown
|
||||
created_at: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.audit()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>审计</h1>
|
||||
<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><th>管理员</th><th>meta</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in items" :key="a.id">
|
||||
<td>{{ a.created_at }}</td>
|
||||
<td>{{ a.action }}</td>
|
||||
<td>{{ a.target_type }} {{ a.target_id }}</td>
|
||||
<td>{{ a.admin_id }}</td>
|
||||
<td><code>{{ JSON.stringify(a.meta) }}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||
code { font-size: 0.8rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value.trim(), password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
await router.replace(redirect)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<form class="card login" @submit.prevent="onSubmit">
|
||||
<h1>运营后台</h1>
|
||||
<p class="muted">愈心谷内部工具 · Phase A</p>
|
||||
<label class="field">
|
||||
<span>用户名</span>
|
||||
<input v-model="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<button class="btn" type="submit" :disabled="loading">
|
||||
{{ loading ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
|
||||
.login { width: min(380px, 100%); }
|
||||
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; }
|
||||
.muted { margin: 0 0 1.2rem; }
|
||||
.btn { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<
|
||||
Array<{
|
||||
id: string
|
||||
user_id: string
|
||||
kind: string
|
||||
plan?: string
|
||||
amount_cents: number
|
||||
status: string
|
||||
created_at: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.orders()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>订单</h1>
|
||||
<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>ID</th><th>用户</th><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in items" :key="o.id">
|
||||
<td>{{ o.id }}</td>
|
||||
<td>{{ o.user_id }}</td>
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ o.created_at }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { adminApi, type UserDetail } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detail = ref<UserDetail | null>(null)
|
||||
const plan = ref('month')
|
||||
const grantMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
detail.value = await adminApi.user(String(route.params.id))
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function grant() {
|
||||
grantMsg.value = ''
|
||||
try {
|
||||
await adminApi.grant(String(route.params.id), plan.value)
|
||||
grantMsg.value = '已授予'
|
||||
await load()
|
||||
} catch (e) {
|
||||
grantMsg.value = e instanceof Error ? e.message : '授予失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>用户详情</h1>
|
||||
<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">
|
||||
<p><strong>ID</strong> {{ detail.id }}</p>
|
||||
<p><strong>状态</strong> {{ detail.status }}</p>
|
||||
<p><strong>创建</strong> {{ detail.created_at }}</p>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="detail.membership">
|
||||
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||
{{ detail.membership.plan || '—' }} ·
|
||||
{{ detail.membership.status }} ·
|
||||
到期 {{ detail.membership.expires_at || '—' }}
|
||||
</p>
|
||||
<div 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>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>档案</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<ul v-else>
|
||||
<li v-for="p in detail.profiles" :key="p.id">
|
||||
{{ p.display_name || '未命名' }}({{ p.relation }})· {{ p.id }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>近订单</h2>
|
||||
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>ID</th><th>类型</th><th>状态</th><th>金额</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||
<td>{{ o.id }}</td>
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.grant { display: flex; 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; }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
const q = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Array<{ id: string; status: string; created_at: string }>>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.users(q.value.trim())
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<h1>用户</h1>
|
||||
<form class="search" @submit.prevent="load">
|
||||
<input v-model="q" placeholder="精确 user id (UUID)" />
|
||||
<button class="btn" type="submit">查询</button>
|
||||
</form>
|
||||
</header>
|
||||
<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>ID</th><th>状态</th><th>创建时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in items" :key="u.id">
|
||||
<td><RouterLink :to="`/users/${u.id}`">{{ u.id }}</RouterLink></td>
|
||||
<td>{{ u.status }}</td>
|
||||
<td>{{ u.created_at }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; justify-content: space-between; margin-bottom: 1rem; }
|
||||
h1 { margin: 0; font-size: 1.35rem; }
|
||||
.search { display: flex; gap: 0.5rem; }
|
||||
.search input { min-width: 280px; border: 1px solid var(--line); border-radius: 8px; padding: 0.55rem 0.75rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getToken } from '@/api/client'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: () => import('@/pages/LoginPage.vue'), meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/AdminShell.vue'),
|
||||
children: [
|
||||
{ path: '', 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: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.public) return true
|
||||
if (!getToken()) return { name: 'login', query: { redirect: to.fullPath } }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { adminApi, getToken, setToken } from '@/api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(getToken())
|
||||
const username = ref<string>('')
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
if (!token.value) return false
|
||||
try {
|
||||
const me = await adminApi.me()
|
||||
username.value = me.username
|
||||
return true
|
||||
} catch {
|
||||
setToken(null)
|
||||
token.value = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await adminApi.logout()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setToken(null)
|
||||
token.value = null
|
||||
username.value = ''
|
||||
}
|
||||
|
||||
return { token, username, login, logout, hydrate }
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
:root {
|
||||
--bg: #f3f0eb;
|
||||
--panel: #fffdf9;
|
||||
--ink: #1c1917;
|
||||
--muted: #78716c;
|
||||
--line: #e7e5e4;
|
||||
--accent: #c45c4a;
|
||||
--accent-ink: #fff;
|
||||
--danger: #b91c1c;
|
||||
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, #f7f3ee 0%, #ebe6df 45%, #f3efea 100%); }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, select { font: inherit; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--line); font-size: 0.92rem; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
.btn {
|
||||
border: 0; border-radius: 8px; padding: 0.55rem 1rem; cursor: pointer;
|
||||
background: var(--accent); color: var(--accent-ink);
|
||||
}
|
||||
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
|
||||
.field input, .field select {
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.75rem; background: #fff;
|
||||
}
|
||||
.err { color: var(--danger); font-size: 0.9rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.1rem 1.2rem;
|
||||
}
|
||||
Reference in New Issue
Block a user