rebuild(stage-4): deliver shared shell and account interfaces

This commit is contained in:
leefer
2026-07-30 02:08:20 +08:00
parent d1c658d8ef
commit 3b75bf2d63
47 changed files with 3042 additions and 99 deletions
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { api } from "../../shared/api/client";
import { useUiStore } from "../../shared/stores/ui";
type CredentialStatus = { name: string; configured: boolean; updated_at: string | null };
const labels: Record<string, string> = {
tushare_token: "Tushare Token",
ifind_refresh_token: "iFinD Refresh Token",
ifind_access_token: "iFinD Access Token",
};
const ui = useUiStore();
const statuses = ref<CredentialStatus[]>([]);
const values = reactive<Record<string, string>>({});
const loading = ref(true);
const errorMessage = ref("");
async function load(): Promise<void> {
loading.value = true;
try {
statuses.value = await api.get<CredentialStatus[]>("/admin/system/credentials");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "行情凭据读取失败。";
} finally {
loading.value = false;
}
}
async function save(name: string): Promise<void> {
const value = values[name]?.trim();
if (!value) {
errorMessage.value = "请输入需要保存的凭据。";
return;
}
errorMessage.value = "";
try {
await api.put(`/admin/system/credentials/${name}`, { value });
values[name] = "";
await load();
ui.showToast("行情凭据已保存");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "保存失败,请稍后重试。";
}
}
onMounted(load);
</script>
<template>
<div class="system-stack">
<section class="card">
<header class="card-header"><h2>平台行情凭据</h2><span class="faint">保存后不再返回明文</span></header>
<div class="card-body credential-list">
<p v-if="loading" class="muted">正在读取行情配置</p>
<div v-for="status in statuses" v-else :key="status.name" class="credential-row">
<div class="credential-meta">
<strong>{{ labels[status.name] ?? status.name }}</strong>
<span :class="status.configured ? 'up' : 'faint'">{{ status.configured ? "已配置" : "未配置" }}</span>
</div>
<input v-model="values[status.name]" class="input" type="password" autocomplete="off" placeholder="输入新值后保存" />
<button class="btn" type="button" @click="save(status.name)">保存</button>
</div>
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
</div>
</section>
<section class="card">
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
<div class="card-body disabled-row">
<p class="muted">历史数据回补将在行情数据迁移完成后开放</p>
<button class="btn" type="button" disabled>开始回补</button>
</div>
</section>
</div>
</template>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../../shared/api/client";
import { useUiStore } from "../../shared/stores/ui";
type Membership = {
status: string;
active: boolean;
is_permanent: boolean;
expires_at: string | null;
daily_limit: number;
};
type AccountMembership = { user_id: number; username: string; is_admin: boolean; membership: Membership };
const ui = useUiStore();
const accounts = ref<AccountMembership[]>([]);
const selectedId = ref<number | null>(null);
const duration = ref("1_month");
const dailyLimit = ref(50);
const errorMessage = ref("");
const confirmDisable = ref(false);
const selected = computed(() => accounts.value.find((item) => item.user_id === selectedId.value) ?? null);
const limitDescending = ref(false);
const sortedAccounts = computed(() =>
[...accounts.value].sort((left, right) => {
const difference = left.membership.daily_limit - right.membership.daily_limit;
return limitDescending.value ? -difference : difference;
}),
);
async function load(): Promise<void> {
try {
accounts.value = await api.get<AccountMembership[]>("/admin/memberships");
if (selectedId.value === null && accounts.value[0]) select(accounts.value[0]);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "会员列表读取失败。";
}
}
function select(account: AccountMembership): void {
selectedId.value = account.user_id;
dailyLimit.value = account.membership.daily_limit;
confirmDisable.value = false;
}
async function disable(): Promise<void> {
if (!confirmDisable.value) {
confirmDisable.value = true;
return;
}
await update("disable");
confirmDisable.value = false;
}
async function update(action: "activate" | "disable" | "set_limit"): Promise<void> {
if (!selected.value) return;
errorMessage.value = "";
try {
await api.patch(`/admin/memberships/${selected.value.user_id}`, {
action,
...(action === "activate" ? { duration: duration.value } : {}),
daily_limit: dailyLimit.value,
});
await load();
ui.showToast(action === "disable" ? "会员已停用" : "会员设置已保存");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "会员设置保存失败。";
}
}
onMounted(load);
</script>
<template>
<div class="system-grid member-grid">
<section class="card">
<header class="card-header"><h2>账号会员状态</h2><span class="tag">{{ accounts.length }} 个账号</span></header>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>账号</th><th>身份</th><th>会员状态</th><th>到期时间</th><th class="num" :aria-sort="limitDescending ? 'descending' : 'ascending'"><button class="sort-button" type="button" @click="limitDescending = !limitDescending">每日上限 <span aria-hidden="true">{{ limitDescending ? "↓" : "↑" }}</span></button></th></tr></thead>
<tbody>
<tr v-for="account in sortedAccounts" :key="account.user_id" :class="{ selected: selectedId === account.user_id }" @click="select(account)">
<td class="name-cell">{{ account.username }}</td><td>{{ account.is_admin ? "管理员" : "用户" }}</td><td>{{ account.membership.active ? "有效" : account.membership.status === "disabled" ? "停用" : "未开通" }}</td><td class="numeric">{{ account.membership.is_permanent ? "永久" : account.membership.expires_at?.slice(0, 10) || "" }}</td><td class="num numeric">{{ account.membership.daily_limit }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card member-editor">
<header class="card-header"><h2>会员设置</h2></header>
<div v-if="selected" class="card-body form-grid">
<div class="selected-account"><strong>{{ selected.username }}</strong><span class="tag">{{ selected.membership.active ? "有效" : "未开通" }}</span></div>
<div class="field"><label for="membership-duration">开通或续期时长</label><select id="membership-duration" v-model="duration" class="select"><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></div>
<div class="field"><label for="daily-limit">每日智能分析上限</label><input id="daily-limit" v-model.number="dailyLimit" class="input numeric" type="number" min="1" max="1000" /></div>
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
<button class="btn btn-primary" type="button" @click="update('activate')">开通 / 续期</button>
<button class="btn" type="button" @click="update('set_limit')">保存每日上限</button>
<button class="btn" type="button" :disabled="!selected.membership.active" @click="disable">{{ confirmDisable ? "确认停用" : "停用会员" }}</button>
</div>
<p v-else class="panel-loading muted">请选择账号</p>
</section>
</div>
</template>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../../shared/api/client";
import { useUiStore } from "../../shared/stores/ui";
type ModelItem = {
id: number;
display_name: string;
base_url: string;
model_identifier: string;
has_api_key: boolean;
is_primary: boolean;
is_fallback: boolean;
updated_at: string;
};
const ui = useUiStore();
const models = ref<ModelItem[]>([]);
const editingId = ref<number | null>(null);
const primaryId = ref<number | null>(null);
const fallbackId = ref<number | null>(null);
const errorMessage = ref("");
const deleteConfirmation = ref<number | null>(null);
const form = reactive({ display_name: "", base_url: "", model_identifier: "", api_key: "" });
const editing = computed(() => models.value.find((item) => item.id === editingId.value) ?? null);
function resetForm(): void {
editingId.value = null;
Object.assign(form, { display_name: "", base_url: "", model_identifier: "", api_key: "" });
}
function edit(model: ModelItem): void {
deleteConfirmation.value = null;
editingId.value = model.id;
Object.assign(form, {
display_name: model.display_name,
base_url: model.base_url,
model_identifier: model.model_identifier,
api_key: "",
});
}
async function load(): Promise<void> {
try {
models.value = await api.get<ModelItem[]>("/admin/models");
primaryId.value = models.value.find((item) => item.is_primary)?.id ?? null;
fallbackId.value = models.value.find((item) => item.is_fallback)?.id ?? null;
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "模型池读取失败。";
}
}
async function save(): Promise<void> {
errorMessage.value = "";
const wasEditing = editingId.value !== null;
try {
const payload = {
display_name: form.display_name,
base_url: form.base_url,
model_identifier: form.model_identifier,
...(form.api_key ? { api_key: form.api_key } : {}),
};
if (editingId.value) {
await api.put(`/admin/models/${editingId.value}`, payload);
} else {
if (!form.api_key) {
errorMessage.value = "新增模型时必须填写密钥。";
return;
}
await api.post("/admin/models", payload);
}
resetForm();
await load();
ui.showToast(wasEditing ? "模型已更新" : "模型已添加");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "模型保存失败。";
}
}
async function saveSelection(): Promise<void> {
if (!primaryId.value) {
errorMessage.value = "请选择主模型。";
return;
}
try {
await api.put("/admin/models/selection", {
primary_model_id: primaryId.value,
fallback_model_id: fallbackId.value,
});
await load();
ui.showToast("主模型和辅助模型已更新");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "模型选择保存失败。";
}
}
async function remove(model: ModelItem): Promise<void> {
if (deleteConfirmation.value !== model.id) {
deleteConfirmation.value = model.id;
return;
}
try {
await api.delete(`/admin/models/${model.id}`);
if (editingId.value === model.id) resetForm();
await load();
deleteConfirmation.value = null;
ui.showToast("模型已删除");
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "模型删除失败。";
}
}
onMounted(load);
</script>
<template>
<div class="system-grid model-grid">
<section class="card">
<header class="card-header"><h2>模型池</h2><span class="tag">{{ models.length }} / 20</span></header>
<div class="model-list">
<button v-for="model in models" :key="model.id" class="model-row" :class="{ active: editingId === model.id }" type="button" @click="edit(model)">
<span><strong>{{ model.display_name }}</strong><small>{{ model.model_identifier }}</small></span>
<span class="model-tags"><span v-if="model.is_primary" class="tag">主模型</span><span v-if="model.is_fallback" class="tag">辅助</span></span>
</button>
<p v-if="!models.length" class="panel-loading muted">尚未添加模型</p>
</div>
</section>
<div class="system-stack">
<section class="card">
<header class="card-header"><h2>{{ editing ? "编辑模型" : "添加模型" }}</h2><button v-if="editing" class="btn btn-small" type="button" @click="resetForm">取消编辑</button></header>
<form class="card-body form-grid" @submit.prevent="save">
<div class="field"><label for="model-name">显示名称</label><input id="model-name" v-model="form.display_name" class="input" required /></div>
<div class="field"><label for="model-url">服务地址</label><input id="model-url" v-model="form.base_url" class="input" type="url" required /></div>
<div class="field"><label for="model-identifier">模型标识</label><input id="model-identifier" v-model="form.model_identifier" class="input" required /></div>
<div class="field"><label for="model-key">密钥</label><input id="model-key" v-model="form.api_key" class="input" type="password" autocomplete="off" :placeholder="editing ? '留空则保持原密钥' : '请输入密钥'" /></div>
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
<div class="form-actions"><button v-if="editing && !editing.is_primary && !editing.is_fallback" class="btn" type="button" @click="remove(editing)">{{ deleteConfirmation === editing.id ? "确认删除" : "删除" }}</button><button class="btn btn-primary" type="submit">{{ editing ? "保存修改" : "添加模型" }}</button></div>
</form>
</section>
<section class="card">
<header class="card-header"><h2>调用顺序</h2></header>
<form class="card-body selection-grid" @submit.prevent="saveSelection">
<div class="field"><label for="primary-model">主模型</label><select id="primary-model" v-model="primaryId" class="select" required><option :value="null" disabled>请选择</option><option v-for="model in models" :key="model.id" :value="model.id">{{ model.display_name }}</option></select></div>
<div class="field"><label for="fallback-model">辅助模型</label><select id="fallback-model" v-model="fallbackId" class="select"><option :value="null">关闭</option><option v-for="model in models" :key="model.id" :value="model.id">{{ model.display_name }}</option></select></div>
<button class="btn" type="submit" :disabled="!models.length">保存选择</button>
</form>
</section>
</div>
</div>
</template>