This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 12:54:04 +08:00
parent e6c8c18be0
commit f1fce871bb
28 changed files with 2566 additions and 235 deletions

58
src/api/adminCardKeys.js Normal file
View File

@@ -0,0 +1,58 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{ page?: number, pageSize?: number, status?: string }} [params]
*/
export function listAdminCardKeysApi(token, { page = 1, pageSize = 20, status = "all" } = {}) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
status,
});
return adminRequest(token, `/admin/card-keys?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createAdminCardKeysApi(token, payload) {
return adminRequest(token, "/admin/card-keys", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} cardId
* @param {object} payload
*/
export function updateAdminCardKeyApi(token, cardId, payload) {
return adminRequest(token, `/admin/card-keys/${cardId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} cardId */
export function deleteAdminCardKeyApi(token, cardId) {
return adminRequest(token, `/admin/card-keys/${cardId}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,62 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{ page?: number, pageSize?: number, keyword?: string }} [params]
*/
export function listAdminDesktopConfigsApi(
token,
{ page = 1, pageSize = 20, keyword = "" } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
const kw = keyword?.trim();
if (kw) query.set("keyword", kw);
return adminRequest(token, `/admin/desktop-configs?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createAdminDesktopConfigApi(token, payload) {
return adminRequest(token, "/admin/desktop-configs", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} configId
* @param {object} payload
*/
export function updateAdminDesktopConfigApi(token, configId, payload) {
return adminRequest(token, `/admin/desktop-configs/${configId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} configId */
export function deleteAdminDesktopConfigApi(token, configId) {
return adminRequest(token, `/admin/desktop-configs/${configId}`, {
method: "DELETE",
});
}

57
src/api/adminUsers.js Normal file
View File

@@ -0,0 +1,57 @@
import { apiRequest } from "./client.js";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
/**
* @param {string} token
* @param {{ page?: number, pageSize?: number }} [params]
*/
export function listAdminUsersApi(token, { page = 1, pageSize = 20 } = {}) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
return adminRequest(token, `/admin/users?${query}`);
}
/**
* @param {string} token
* @param {object} payload
*/
export function createAdminUserApi(token, payload) {
return adminRequest(token, "/admin/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} userId
* @param {object} payload
*/
export function updateAdminUserApi(token, userId, payload) {
return adminRequest(token, `/admin/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} userId */
export function deleteAdminUserApi(token, userId) {
return adminRequest(token, `/admin/users/${userId}`, {
method: "DELETE",
});
}

View File

@@ -53,6 +53,13 @@ export async function apiRequest(path, options = {}) {
return { ok: false, message: "未登录或登录已过期" };
}
if (res.status === 403) {
return {
ok: false,
message: body?.detail || "无权限执行此操作",
};
}
return {
ok: false,
message: body?.message || `请求失败(${res.status}`,

View File

@@ -1,8 +1,10 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useAuthStore } from "../stores/auth";
const route = useRoute();
const auth = useAuthStore();
const baseMenuItems = [
{ name: "home", label: "首页", to: "/", icon: "home" },
@@ -17,13 +19,24 @@ const baseMenuItems = [
];
const menuItems = computed(() => {
if (import.meta.env.DEV) {
return [...baseMenuItems, { name: "debug", label: "调试", to: "/debug", icon: "debug" }];
const items = [...baseMenuItems];
if (auth.isAdmin) {
items.push({ name: "admin", label: "管理", to: "/admin", icon: "admin" });
}
return baseMenuItems;
if (auth.isAgent) {
items.push({ name: "agent", label: "代理", to: "/agent", icon: "agent" });
}
if (import.meta.env.DEV) {
items.push({ name: "debug", label: "调试", to: "/debug", icon: "debug" });
}
return items;
});
const activeName = computed(() => route.name);
const activeName = computed(() => {
if (route.path.startsWith("/admin")) return "admin";
if (route.path.startsWith("/agent")) return "agent";
return route.name;
});
</script>
<template>
@@ -72,6 +85,14 @@ const activeName = computed(() => route.name);
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.2-1.2 2.2M12 17h.01" stroke-linecap="round" />
</svg>
<svg v-else-if="item.icon === 'admin'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 3l8 4v6c0 5-3.5 8-8 8s-8-3-8-8V7l8-4z" stroke-linejoin="round" />
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else-if="item.icon === 'agent'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="8" r="3" />
<path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6" stroke-linecap="round" />
</svg>
<svg v-else-if="item.icon === 'debug'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 6v2M12 16v2M6 12h2M16 12h2" stroke-linecap="round" />
<circle cx="12" cy="12" r="4" />

View File

@@ -0,0 +1,30 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const menuItems = [
{ name: "admin-overview", label: "概览", to: "/admin" },
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
];
const activeName = computed(() => route.name);
</script>
<template>
<nav class="admin-sub-nav" aria-label="管理后台导航">
<p class="admin-sub-nav__title">管理后台</p>
<RouterLink
v-for="item in menuItems"
:key="item.name"
:to="item.to"
class="admin-sub-nav__item"
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
>
{{ item.label }}
</RouterLink>
</nav>
</template>

View File

@@ -4,7 +4,12 @@ import { storeToRefs } from "pinia";
import { useWorkflowStore, executeVideoRewrite } from "../../stores/workflow.js";
const workflow = useWorkflowStore();
const { videoLinkModalVisible, videoShareText, videoRewriting } = storeToRefs(workflow);
const {
videoLinkModalVisible,
videoShareText,
videoRewriting,
rewriteProgress,
} = storeToRefs(workflow);
const feedback = ref({ severity: "", message: "" });
@@ -12,8 +17,7 @@ const tipItems = [
"已支持抖音分享文本、抖音视频链接、抖音短链接",
"支持直接视频文件 URL如 .mp4、.mov 等)",
"快手、小红书、B站、视频号分享链接目前会提示暂不支持",
"使用 FUNASR 进行语音识别",
"根据设置的仿写提示词生成仿写文案",
"流程:解析链接 → 下载视频 → 提取音频 → 语音识别 → AI 仿写",
];
function onCancel() {
@@ -26,6 +30,8 @@ async function onRewrite() {
const result = await executeVideoRewrite(workflow);
if (!result.ok) {
feedback.value = { severity: "error", message: result.message };
} else {
feedback.value = { severity: "success", message: result.message };
}
}
</script>
@@ -49,6 +55,15 @@ async function onRewrite() {
{{ feedback.message }}
</Message>
<Message
v-if="videoRewriting && rewriteProgress"
severity="info"
:closable="false"
class="w-full"
>
{{ rewriteProgress }}
</Message>
<div>
<label class="mb-2 block font-medium text-slate-200">
请粘贴视频分享文本或视频链接

View File

@@ -0,0 +1,69 @@
/**
* 流水线配置校验:优先使用 Rust 缓存的 desktop_configsget_app_config
* 实际密钥由 Node 子进程通过环境变量 AICLIENT_CFG_* 读取,无需在前端 params 传递。
*/
import { invoke } from "@tauri-apps/api/core";
/** @returns {Promise<{ ok: true } | { ok: false, message: string }>} */
export async function ensureAppConfigReady() {
try {
const map = await invoke("get_app_config");
if (map && typeof map === "object" && Object.keys(map).length > 0) {
const apiKey =
map.LLM_API_KEY ||
map.llm_api_key ||
map["llm.api_key"];
if (!apiKey) {
return {
ok: false,
message: "服务端配置缺少 LLM_API_KEY请在 desktop_configs 表中维护",
};
}
return { ok: true };
}
const serverMsg = await invoke("get_app_config_last_message");
if (serverMsg) {
return { ok: false, message: String(serverMsg) };
}
return {
ok: false,
message: "未获取到应用配置,请先登录并确保 desktop_configs 表有数据",
};
} catch {
return {
ok: false,
message: "请在 Tauri 桌面端登录后使用(配置由服务端下发)",
};
}
}
const DOUYIN_RE = /douyin\.com|iesdouyin\.com|v\.douyin\.com/i;
const DIRECT_VIDEO_RE = /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i;
/** @returns {{ ok: true, value: string } | { ok: false, message: string }} */
export function validateShareText(text) {
const raw = String(text || "").trim();
if (!raw) {
return { ok: false, message: "请粘贴视频分享文本或视频链接" };
}
const urls = raw.match(/https?:\/\/[^\s\u4e00-\u9fa5]+/gi) || [];
if (urls.some((u) => DIRECT_VIDEO_RE.test(u))) {
return { ok: true, value: raw };
}
if (DOUYIN_RE.test(raw) || urls.some((u) => DOUYIN_RE.test(u))) {
return { ok: true, value: raw };
}
return {
ok: false,
message:
"请输入抖音分享文本、抖音视频链接或直接视频文件链接mp4/mov/webm/mkv/avi",
};
}
export const DEFAULT_REWRITE_CONFIG = {
style: "casual",
length: "medium",
keepHashtags: true,
keepEmoji: true,
};

View File

@@ -19,6 +19,9 @@ import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
import Dialog from "primevue/dialog";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Tag from "primevue/tag";
import DashboardCard from "./components/dashboard/DashboardCard.vue";
import App from "./App.vue";
@@ -59,6 +62,9 @@ app.component("TabPanels", TabPanels);
app.component("TabPanel", TabPanel);
app.component("DashboardCard", DashboardCard);
app.component("Dialog", Dialog);
app.component("DataTable", DataTable);
app.component("Column", Column);
app.component("Tag", Tag);
useAuthStore(pinia).hydrateRustSession();

View File

@@ -1,112 +1,317 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/auth";
const placeholder = () => import("../views/PlaceholderView.vue");
const mainChildren = [
{
path: "",
name: "home",
component: () => import("../views/HomeView.vue"),
},
{
path: "avatar",
name: "avatar",
component: placeholder,
meta: { title: "形象" },
},
{
path: "material",
name: "material",
component: placeholder,
meta: { title: "素材" },
},
{
path: "voice",
name: "voice",
component: placeholder,
meta: { title: "声音" },
},
{
path: "compose",
name: "compose",
component: placeholder,
meta: { title: "合成" },
},
{
path: "extract",
name: "extract",
component: placeholder,
meta: { title: "提取" },
},
{
path: "design",
name: "design",
component: placeholder,
meta: { title: "设计" },
},
{
path: "model",
name: "model",
component: placeholder,
meta: { title: "模型" },
},
{
path: "help",
name: "help",
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../stores/auth";
const placeholder = () => import("../views/PlaceholderView.vue");
const mainChildren = [
{
path: "",
name: "home",
component: () => import("../views/HomeView.vue"),
},
{
path: "avatar",
name: "avatar",
component: placeholder,
meta: { title: "形象" },
},
{
path: "material",
name: "material",
component: placeholder,
meta: { title: "素材" },
},
{
path: "voice",
name: "voice",
component: placeholder,
meta: { title: "声音" },
},
{
path: "compose",
name: "compose",
component: placeholder,
meta: { title: "合成" },
},
{
path: "extract",
name: "extract",
component: placeholder,
meta: { title: "提取" },
},
{
path: "design",
name: "design",
component: placeholder,
meta: { title: "设计" },
},
{
path: "model",
name: "model",
component: placeholder,
meta: { title: "模型" },
},
{
path: "help",
name: "help",
component: placeholder,
meta: { title: "帮助" },
},
{
path: "admin",
component: () => import("../views/AdminView.vue"),
meta: { title: "管理", requiresRole: ROLE_ADMIN },
redirect: { name: "admin-overview" },
children: [
{
path: "",
name: "admin-overview",
component: () => import("../views/admin/AdminOverviewView.vue"),
meta: { title: "概览" },
},
{
path: "users",
name: "admin-users",
component: () => import("../views/admin/AdminUsersView.vue"),
meta: { title: "用户管理" },
},
{
path: "desktop-config",
name: "admin-desktop-config",
component: () => import("../views/admin/AdminDesktopConfigView.vue"),
meta: { title: "桌面配置" },
},
{
path: "card-keys",
name: "admin-card-keys",
component: () => import("../views/admin/AdminCardKeysView.vue"),
meta: { title: "卡密管理" },
},
],
},
{
path: "agent",
name: "agent",
component: () => import("../views/AgentView.vue"),
meta: { title: "代理", requiresRole: ROLE_AGENT },
},
];
if (import.meta.env.DEV) {
mainChildren.push({
path: "debug",
name: "debug",
component: () => import("../views/DebugView.vue"),
meta: { title: "调试", devOnly: true },
});
}
const routes = [
{
path: "/",
component: () => import("../layouts/MainLayout.vue"),
meta: { requiresAuth: true },
children: mainChildren,
},
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
},
{
path: "/register",
name: "register",
component: () => import("../views/RegisterView.vue"),
},
{
path: "/:pathMatch(.*)*",
redirect: "/login",
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
if (requiresAuth && !auth.isAuthenticated) {
return { name: "login" };
}
if ((to.name === "login" || to.name === "register") && auth.isAuthenticated) {
return { name: "home" };
}
const requiredRole = to.matched
.map((record) => record.meta.requiresRole)
.find((role) => role != null);
if (requiredRole != null && auth.roleId !== requiredRole) {
return { name: "home" };
}
});
export default router;

View File

@@ -5,6 +5,11 @@ import { loginApi, logoutApi, registerApi } from "../api/auth.js";
const TOKEN_KEY = "aiclient_token";
const USER_KEY = "aiclient_current_user";
/** 管理员 */
export const ROLE_ADMIN = 1;
/** 代理 */
export const ROLE_AGENT = 2;
async function syncAuthSessionToRust(token, user) {
try {
await invoke("sync_auth_session", {
@@ -29,6 +34,7 @@ function applyTokenResponse(store, res, fallbackMessage) {
store.setSession(data.access_token, {
id: data.user.id,
username: data.user.username,
role_id: data.user.role_id,
});
return { ok: true, message: res.message || fallbackMessage };
@@ -49,6 +55,9 @@ export const useAuthStore = defineStore("auth", {
getters: {
isAuthenticated: (state) => Boolean(state.token),
roleId: (state) => state.currentUser?.role_id ?? null,
isAdmin: (state) => state.currentUser?.role_id === ROLE_ADMIN,
isAgent: (state) => state.currentUser?.role_id === ROLE_AGENT,
},
actions: {

View File

@@ -1,238 +1,486 @@
import { defineStore, acceptHMRUpdate } from "pinia";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
ensureAppConfigReady,
validateShareText,
DEFAULT_REWRITE_CONFIG,
} from "../config/videoPipeline.js";
const PIPELINE_SCRIPT = "douyin_pipeline.js";
const NODEJS_EVENT = "nodejs:event";
const defaultPublishPlatforms = () => [
{ label: "*音", checked: false, account: null },
{ label: "*手", checked: false, account: null },
{ label: "*频号", checked: false, account: null },
{ label: "*红书", checked: false, account: null },
];
export const useWorkflowStore = defineStore("workflow", {
state: () => ({
// 01 IP 深度学习
ipMode: "ipBrain",
ipBrainModalVisible: false,
ipBrainProfileUrl: "",
ipBrainCollecting: false,
ipBenchmarks: [],
ipTopics: [],
ipBrainSelectedBenchmarkId: null,
videoLinkModalVisible: false,
videoShareText: "",
videoRewriting: false,
/** 流水线进度文案(来自 quickjs:event progress */
rewriteProgress: "",
recognizedContent: "",
/** 自定义仿写提示词(可选,对应 zhenqianba rewritePrompt */
rewritePrompt: "",
videoType: "口播文案",
copyType: "人设型",
industryPersona: "",
productBusiness: "",
sellingPrice: "",
otherRequirements: "",
targetWords: 300,
// 视频文案编辑
scriptContent: "",
editLanguage: "中文(普通话)",
editWordCount: 300,
// 02 音视频生成
voiceEmotion: "自然",
speechRate: 1,
voiceLanguage: "中文(普通话)",
avatarSelect: null,
// 03 视频编辑
pipInPicture: false,
autoCutBreath: false,
greenScreen: false,
// 04 标题标签关键词
titleGenerated: "",
tagsGenerated: "",
keywordTab: "0",
keywordsFocus: "",
keywordsDescribe: "",
keywordsAction: "",
keywordsEmotion: "",
// 05 字幕和音乐
autoSubtitle: false,
smartSubtitle: true,
bgmEnabled: false,
bgmVolume: 30,
subtitleTemplate: null,
// 07 视频发布
publishPlatforms: defaultPublishPlatforms(),
}),
getters: {
ipModes: () => [
{ label: "IP学习", value: "ipBrain" },
{ label: "视频学习", value: "videoWrite" },
{ label: "爆款文案", value: "customPrompt" },
],
videoTypes: () => ["口播文案", "剧情文案", "带货文案"],
copyTypes: () => ["人设型", "卖点型", "故事型"],
voiceEmotions: () => ["自然", "热情", "沉稳"],
languages: () => ["中文(普通话)", "中文(粤语)", "English"],
avatarOptions: () => [{ label: "请选择形象", value: null }],
keywordCountFocus: (state) => countLines(state.keywordsFocus),
keywordCountDescribe: (state) => countLines(state.keywordsDescribe),
keywordCountAction: (state) => countLines(state.keywordsAction),
keywordCountEmotion: (state) => countLines(state.keywordsEmotion),
ipBenchmarkCount: (state) => state.ipBenchmarks.length,
selectedBenchmark: (state) =>
state.ipBenchmarks.find((b) => b.id === state.ipBrainSelectedBenchmarkId) ?? null,
},
actions: {
openIpBrainDialog() {
this.ipBrainModalVisible = true;
},
closeIpBrainDialog() {
this.ipBrainModalVisible = false;
},
async startIpBrainCollect() {
const url = this.ipBrainProfileUrl?.trim();
if (!url) {
return { ok: false, message: "请输入账号主页或分享链接" };
}
if (this.ipBenchmarks.length >= 5) {
return { ok: false, message: "最多添加 5 个对标账号" };
}
this.ipBrainCollecting = true;
try {
// TODO: 对接后端采集 API / Tauri 浏览器自动化
await new Promise((r) => setTimeout(r, 800));
const benchmark = {
id: `bm_${Date.now()}`,
url,
label: truncateUrl(url),
};
this.ipBenchmarks.push(benchmark);
this.ipBrainSelectedBenchmarkId = benchmark.id;
this.ipTopics = Array.from({ length: 5 }, (_, i) => ({
id: `topic_${Date.now()}_${i}`,
title: `示例视频标题 ${i + 1}(待对接真实采集)`,
}));
this.ipBrainProfileUrl = "";
this.ipBrainModalVisible = false;
return { ok: true, message: "采集完成" };
} finally {
this.ipBrainCollecting = false;
}
},
selectBenchmark(id) {
this.ipBrainSelectedBenchmarkId = id;
this.ipTopics = [];
},
openVideoLinkDialog() {
this.videoLinkModalVisible = true;
},
closeVideoLinkDialog() {
this.videoLinkModalVisible = false;
},
async startVideoRewrite() {
return executeVideoRewrite(this);
},
resetAll() {
this.$reset();
this.publishPlatforms = defaultPublishPlatforms();
},
clearData() {
this.resetAll();
},
},
});
export async function processDouyinShare(shareText) {
const result = await invoke("run_quickjs_script", {
scriptName: "douyin_pipeline.js",
params: {
shareText: shareText ?? "",
modelConfig: {
providerId: "openai",
modelId: "gpt-4o-mini",
apiUrl: "https://api.openai.com", // 或 dashscope/openrouter 等任意兼容端点
apiKey: "sk-...",
asrMode: "online",
aliyunApiKey: "sk-dashscope-...",
ossConfig: {
accessKeyId: "...",
accessKeySecret: "...",
bucket: "my-bucket",
region: "oss-cn-hangzhou",
},
},
rewriteConfig: { style: "幽默口播", tone: "活泼" },
customPrompt: null,
},
});
return result;
}
/**
* 一键仿写Node.js douyin_pipeline对齐 zhenqianba ipAgent:processDouyinShare
* @param {ReturnType<typeof useWorkflowStore>} store
*/
export async function executeVideoRewrite(store) {
const text = store.videoShareText?.trim();
if (!text) {
return { ok: false, message: "请粘贴视频分享文本或视频链接" };
const parsed = validateShareText(store.videoShareText);
if (!parsed.ok) {
return { ok: false, message: parsed.message };
}
const unsupported = detectUnsupportedPlatform(text);
const unsupported = detectUnsupportedPlatform(parsed.value);
if (unsupported) {
return { ok: false, message: unsupported };
}
store.videoRewriting = true;
try {
// TODO: 对接 FUNASR 语音识别与仿写 API
await new Promise((r) => setTimeout(r, 1000));
const cfgReady = await ensureAppConfigReady();
if (!cfgReady.ok) {
return { ok: false, message: cfgReady.message };
}
store.videoRewriting = true;
store.rewriteProgress = "正在解析和仿写...";
let unlisten = null;
try {
try {
unlisten = await listen(NODEJS_EVENT, (e) => {
const p = e.payload;
if (p?.type === "progress" && typeof p.status === "string") {
store.rewriteProgress = p.status;
}
});
} catch {
/* 非 Tauri 环境 */
}
const result = await invoke("run_nodejs_script", {
scriptName: PIPELINE_SCRIPT,
params: {
shareText: parsed.value,
rewriteConfig: { ...DEFAULT_REWRITE_CONFIG },
customPrompt: store.rewritePrompt?.trim() || null,
},
});
if (!result || result.success !== true) {
const err =
(result && (result.error || result.message)) ||
"解析或仿写失败,请检查链接与模型配置";
return { ok: false, message: String(err) };
}
const original = result.original || {};
const rewritten = result.rewritten || {};
store.recognizedContent = original.description || "";
store.scriptContent =
rewritten.fullContent || rewritten.description || "";
if (rewritten.title) {
store.titleGenerated = rewritten.title;
}
if (original.hashtags?.length) {
store.tagsGenerated = original.hashtags.join(" ");
}
store.recognizedContent =
"【示例识别结果】这里是 FUNASR 语音识别后的原始文案内容,待对接后端后将显示真实结果。\n\n" +
`来源:${truncateUrl(text, 48)}`;
store.videoShareText = "";
store.videoLinkModalVisible = false;
return { ok: true, message: "仿写完成" };
return {
ok: true,
message: "仿写完成,已为您生成新文案",
data: result,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err || "未知错误");
return { ok: false, message: `仿写失败: ${msg}` };
} finally {
if (typeof unlisten === "function") {
unlisten();
}
store.videoRewriting = false;
store.rewriteProgress = "";
}
}
const UNSUPPORTED_PLATFORM_RULES = [
{ pattern: /kuaishou\.com|ksurl\.cn|gifshow\.com/i, name: "快手" },
{ pattern: /xiaohongshu\.com|xhslink\.com/i, name: "小红书" },
{ pattern: /bilibili\.com|b23\.tv/i, name: "B站" },
{ pattern: /channels\.weixin\.qq\.com|finder\.video\.qq\.com/i, name: "视频号" },
];
function detectUnsupportedPlatform(text) {
for (const { pattern, name } of UNSUPPORTED_PLATFORM_RULES) {
if (pattern.test(text)) {
return `${name}分享链接暂不支持,请使用抖音分享文本或视频链接`;
}
}
return null;
}
function truncateUrl(url, max = 36) {
if (url.length <= max) return url;
return `${url.slice(0, max)}`;
}
function countLines(text) {
if (!text?.trim()) return 0;
return text.split("\n").filter((line) => line.trim()).length;
}
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useWorkflowStore, import.meta.hot));
}

View File

@@ -172,6 +172,126 @@ body {
var(--color-bg-base);
}
/* 管理后台嵌套布局 */
.admin-layout {
display: flex;
height: 100%;
min-height: 0;
}
.admin-layout__content {
flex: 1;
min-width: 0;
overflow: auto;
padding: 24px;
}
.admin-sub-nav {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 200px;
padding: 16px 12px;
gap: 4px;
border-right: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(10, 12, 20, 0.6);
}
.admin-sub-nav__title {
margin: 0 8px 12px;
font-size: 13px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.02em;
}
.admin-sub-nav__item {
display: block;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
color: #94a3b8;
text-decoration: none;
transition:
color 0.2s,
background 0.2s;
}
.admin-sub-nav__item:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.04);
}
.admin-sub-nav__item--active {
color: #e2e8f0;
background: rgba(59, 130, 246, 0.1);
box-shadow: inset 2px 0 0 #3b82f6;
}
.admin-page__title {
margin: 0 0 12px;
font-size: 1.25rem;
font-weight: 600;
}
.admin-users__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.admin-users__message {
margin-bottom: 12px;
}
.admin-users__table {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
overflow: hidden;
}
.admin-users__actions {
display: flex;
gap: 4px;
}
.admin-card-keys__toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.admin-card-keys__status-select {
width: 10rem;
}
.admin-card-keys__serial {
display: flex;
align-items: center;
gap: 4px;
}
.admin-card-keys__serial code {
color: #e2e8f0;
letter-spacing: 0.04em;
}
.admin-desktop-config__search {
flex: 1;
min-width: 12rem;
max-width: 20rem;
}
.admin-desktop-config__value {
font-family: ui-monospace, monospace;
font-size: 12px;
color: #94a3b8;
word-break: break-all;
}
/* 首页工作台 */
.custom-scrollbar {
scrollbar-width: thin;

12
src/views/AdminView.vue Normal file
View File

@@ -0,0 +1,12 @@
<script setup>
import AdminSubNav from "../components/admin/AdminSubNav.vue";
</script>
<template>
<div class="admin-layout">
<AdminSubNav />
<div class="admin-layout__content custom-scrollbar">
<RouterView />
</div>
</div>
</template>

14
src/views/AgentView.vue Normal file
View File

@@ -0,0 +1,14 @@
<script setup>
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
</script>
<template>
<div class="flex h-full flex-col items-center justify-center gap-3 p-8">
<h1 class="gradient-text text-xl font-semibold">代理工作台</h1>
<p class="text-sm text-text-muted">
当前用户{{ auth.currentUser?.username }}代理
</p>
</div>
</template>

View File

@@ -0,0 +1,369 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { useAuthStore } from "../../stores/auth.js";
import {
listAdminCardKeysApi,
createAdminCardKeysApi,
updateAdminCardKeyApi,
deleteAdminCardKeyApi,
} from "../../api/adminCardKeys.js";
const auth = useAuthStore();
const STATUS_OPTIONS = [
{ label: "全部", value: "all" },
{ label: "未使用", value: "unused" },
{ label: "已激活", value: "used" },
];
const cards = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const statusFilter = ref("all");
const loading = ref(false);
const saving = ref(false);
const feedback = ref({ severity: "", message: "" });
const dialogVisible = ref(false);
const dialogMode = ref("create");
const editingId = ref(null);
const emptyForm = () => ({
serial_number: "",
duration_days: 30,
count: 1,
remark: "",
});
const form = ref(emptyForm());
const dialogTitle = computed(() =>
dialogMode.value === "create" ? "生成卡密" : "编辑卡密",
);
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
const editingActivatedAt = ref(null);
function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function showMessage(severity, message) {
feedback.value = { severity, message };
}
async function loadCards() {
loading.value = true;
feedback.value = { severity: "", message: "" };
const res = await listAdminCardKeysApi(auth.token, {
page: page.value,
pageSize: pageSize.value,
status: statusFilter.value,
});
loading.value = false;
if (!res.ok) {
showMessage("error", res.message || "加载卡密列表失败");
return;
}
const data = res.data;
cards.value = Array.isArray(data?.items) ? data.items : [];
total.value = typeof data?.total === "number" ? data.total : 0;
if (typeof data?.page === "number") page.value = data.page;
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
}
async function onPage(event) {
page.value = event.page + 1;
pageSize.value = event.rows;
await loadCards();
}
function onStatusChange() {
page.value = 1;
loadCards();
}
function openCreateDialog() {
dialogMode.value = "create";
editingId.value = null;
editingActivatedAt.value = null;
form.value = emptyForm();
dialogVisible.value = true;
}
function openEditDialog(row) {
dialogMode.value = "edit";
editingId.value = row.id;
editingActivatedAt.value = row.activated_at;
form.value = {
serial_number: row.serial_number,
duration_days: row.duration_days,
count: 1,
remark: row.remark || "",
};
dialogVisible.value = true;
}
async function copySerial(serial) {
try {
await navigator.clipboard.writeText(serial);
showMessage("success", "序列号已复制");
} catch {
showMessage("warn", "复制失败,请手动复制");
}
}
async function saveCard() {
if (form.value.duration_days < 1) {
showMessage("warn", "时长至少 1 天");
return;
}
saving.value = true;
feedback.value = { severity: "", message: "" };
let res;
if (dialogMode.value === "create") {
const payload = {
duration_days: form.value.duration_days,
count: form.value.count,
remark: form.value.remark.trim() || null,
};
const serial = form.value.serial_number.trim();
if (serial) {
if (form.value.count !== 1) {
saving.value = false;
showMessage("warn", "指定序列号时数量只能为 1");
return;
}
payload.serial_number = serial.toUpperCase();
}
res = await createAdminCardKeysApi(auth.token, payload);
} else {
res = await updateAdminCardKeyApi(auth.token, editingId.value, {
duration_days: isActivated.value ? undefined : form.value.duration_days,
remark: form.value.remark.trim() || null,
});
}
saving.value = false;
if (!res.ok) {
showMessage("error", res.message || "保存失败");
return;
}
dialogVisible.value = false;
showMessage("success", res.message || "保存成功");
await loadCards();
}
async function removeCard(row) {
if (row.activated_at) {
showMessage("warn", "已激活卡密不能删除");
return;
}
const ok = window.confirm(`确定删除卡密「${row.serial_number}」?`);
if (!ok) return;
const res = await deleteAdminCardKeyApi(auth.token, row.id);
if (!res.ok) {
showMessage("error", res.message || "删除失败");
return;
}
showMessage("success", res.message || "已删除");
if (cards.value.length === 1 && page.value > 1) {
page.value -= 1;
}
await loadCards();
}
watch(statusFilter, onStatusChange);
onMounted(() => {
loadCards();
});
</script>
<template>
<div class="admin-page admin-card-keys">
<div class="admin-users__header">
<div>
<h1 class="admin-page__title gradient-text">卡密管理</h1>
<p class="text-sm text-text-muted">生成查看与管理 VIP 激活卡密</p>
</div>
<Button label="生成卡密" @click="openCreateDialog" />
</div>
<Message
v-if="feedback.message"
:severity="feedback.severity"
:closable="true"
class="admin-users__message"
@close="feedback.message = ''"
>
{{ feedback.message }}
</Message>
<div class="admin-card-keys__toolbar">
<label class="text-sm text-text-muted">状态筛选</label>
<Select
v-model="statusFilter"
:options="STATUS_OPTIONS"
option-label="label"
option-value="value"
class="admin-card-keys__status-select"
/>
</div>
<DataTable
:value="cards"
:loading="loading"
lazy
paginator
:rows="pageSize"
:total-records="total"
:first="(page - 1) * pageSize"
:rows-per-page-options="[10, 20, 50]"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
current-page-report-template=" {totalRecords} "
striped-rows
size="small"
class="admin-users__table"
data-key="id"
@page="onPage"
>
<Column field="serial_number" header="序列号" style="min-width: 11rem">
<template #body="{ data }">
<div class="admin-card-keys__serial">
<code class="text-xs">{{ data.serial_number }}</code>
<Button
label="复制"
size="small"
severity="secondary"
text
@click="copySerial(data.serial_number)"
/>
</div>
</template>
</Column>
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
<Column field="created_at" header="创建时间">
<template #body="{ data }">
{{ formatDateTime(data.created_at) }}
</template>
</Column>
<Column field="activated_at" header="激活时间">
<template #body="{ data }">
<Tag
v-if="data.activated_at"
:value="formatDateTime(data.activated_at)"
severity="success"
/>
<Tag v-else value="未使用" severity="secondary" />
</template>
</Column>
<Column field="username" header="使用者">
<template #body="{ data }">
{{ data.username || "—" }}
</template>
</Column>
<Column field="remark" header="备注">
<template #body="{ data }">
{{ data.remark || "—" }}
</template>
</Column>
<Column header="操作" style="width: 9rem">
<template #body="{ data }">
<div class="admin-users__actions">
<Button
label="编辑"
size="small"
severity="secondary"
text
@click="openEditDialog(data)"
/>
<Button
label="删除"
size="small"
severity="danger"
text
:disabled="!!data.activated_at"
@click="removeCard(data)"
/>
</div>
</template>
</Column>
<template #empty>
<p class="py-6 text-center text-sm text-text-muted">暂无卡密</p>
</template>
</DataTable>
<Dialog
v-model:visible="dialogVisible"
:header="dialogTitle"
modal
:style="{ width: '28rem' }"
>
<div class="flex flex-col gap-4">
<template v-if="dialogMode === 'create'">
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">时长</label>
<InputNumber v-model="form.duration_days" :min="1" :max="3650" class="w-full" />
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">生成数量</label>
<InputNumber v-model="form.count" :min="1" :max="100" class="w-full" />
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">自定义序列号可选 1 </label>
<InputText
v-model="form.serial_number"
class="w-full font-mono uppercase"
placeholder="留空则自动生成"
:disabled="form.count > 1"
/>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">序列号</label>
<InputText
:model-value="form.serial_number"
class="w-full font-mono"
disabled
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">时长</label>
<InputNumber
v-model="form.duration_days"
:min="1"
:max="3650"
class="w-full"
:disabled="isActivated"
/>
</div>
</template>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">备注</label>
<Textarea v-model="form.remark" rows="2" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
<Button label="保存" :loading="saving" @click="saveCard" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,325 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useAuthStore } from "../../stores/auth.js";
import {
listAdminDesktopConfigsApi,
createAdminDesktopConfigApi,
updateAdminDesktopConfigApi,
deleteAdminDesktopConfigApi,
} from "../../api/adminDesktopConfigs.js";
const auth = useAuthStore();
const configs = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const keyword = ref("");
const loading = ref(false);
const saving = ref(false);
const feedback = ref({ severity: "", message: "" });
const dialogVisible = ref(false);
const dialogMode = ref("create");
const editingId = ref(null);
const emptyForm = () => ({
name: "",
value: "",
mark: "",
});
const form = ref(emptyForm());
const dialogTitle = computed(() =>
dialogMode.value === "create" ? "新建配置" : "编辑配置",
);
const valuePreview = computed(() => {
const v = form.value.value || "";
if (v.length <= 120) return v;
return `${v.slice(0, 120)}`;
});
function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function showMessage(severity, message) {
feedback.value = { severity, message };
}
function truncateValue(value, max = 48) {
if (!value) return "—";
if (value.length <= max) return value;
return `${value.slice(0, max)}`;
}
async function loadConfigs() {
loading.value = true;
feedback.value = { severity: "", message: "" };
const res = await listAdminDesktopConfigsApi(auth.token, {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value,
});
loading.value = false;
if (!res.ok) {
showMessage("error", res.message || "加载配置失败");
return;
}
const data = res.data;
configs.value = Array.isArray(data?.items) ? data.items : [];
total.value = typeof data?.total === "number" ? data.total : 0;
if (typeof data?.page === "number") page.value = data.page;
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
}
async function onPage(event) {
page.value = event.page + 1;
pageSize.value = event.rows;
await loadConfigs();
}
function onSearch() {
page.value = 1;
loadConfigs();
}
function openCreateDialog() {
dialogMode.value = "create";
editingId.value = null;
form.value = emptyForm();
dialogVisible.value = true;
}
function openEditDialog(row) {
dialogMode.value = "edit";
editingId.value = row.id;
form.value = {
name: row.name,
value: row.value,
mark: row.mark || "",
};
dialogVisible.value = true;
}
async function copyName(name) {
try {
await navigator.clipboard.writeText(name);
showMessage("success", "键名已复制");
} catch {
showMessage("warn", "复制失败");
}
}
async function saveConfig() {
const name = form.value.name.trim();
if (!name) {
showMessage("warn", "配置键名不能为空");
return;
}
saving.value = true;
feedback.value = { severity: "", message: "" };
const payload = {
name,
value: form.value.value,
mark: form.value.mark.trim() || null,
};
let res;
if (dialogMode.value === "create") {
res = await createAdminDesktopConfigApi(auth.token, payload);
} else {
res = await updateAdminDesktopConfigApi(auth.token, editingId.value, {
value: payload.value,
mark: payload.mark,
});
}
saving.value = false;
if (!res.ok) {
showMessage("error", res.message || "保存失败");
return;
}
dialogVisible.value = false;
showMessage("success", res.message || "保存成功");
await loadConfigs();
}
async function removeConfig(row) {
const ok = window.confirm(`确定删除配置「${row.name}」?桌面端需重新登录后生效。`);
if (!ok) return;
const res = await deleteAdminDesktopConfigApi(auth.token, row.id);
if (!res.ok) {
showMessage("error", res.message || "删除失败");
return;
}
showMessage("success", res.message || "已删除");
if (configs.value.length === 1 && page.value > 1) {
page.value -= 1;
}
await loadConfigs();
}
onMounted(() => {
loadConfigs();
});
</script>
<template>
<div class="admin-page admin-desktop-config">
<div class="admin-users__header">
<div>
<h1 class="admin-page__title gradient-text">桌面配置</h1>
<p class="text-sm text-text-muted">
管理 desktop_configs 键值下发至桌面端 Node 环境变量AICLIENT_CFG_*
</p>
</div>
<Button label="新建配置" @click="openCreateDialog" />
</div>
<Message
v-if="feedback.message"
:severity="feedback.severity"
:closable="true"
class="admin-users__message"
@close="feedback.message = ''"
>
{{ feedback.message }}
</Message>
<div class="admin-card-keys__toolbar">
<InputText
v-model="keyword"
placeholder="按键名搜索,如 LLM_API_KEY"
class="admin-desktop-config__search"
@keyup.enter="onSearch"
/>
<Button label="搜索" severity="secondary" @click="onSearch" />
</div>
<DataTable
:value="configs"
:loading="loading"
lazy
paginator
:rows="pageSize"
:total-records="total"
:first="(page - 1) * pageSize"
:rows-per-page-options="[10, 20, 50]"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
current-page-report-template=" {totalRecords} "
striped-rows
size="small"
class="admin-users__table"
data-key="id"
@page="onPage"
>
<Column field="name" header="键名" style="min-width: 10rem">
<template #body="{ data }">
<div class="admin-card-keys__serial">
<code class="text-xs">{{ data.name }}</code>
<Button label="复制" size="small" severity="secondary" text @click="copyName(data.name)" />
</div>
</template>
</Column>
<Column field="value" header="值">
<template #body="{ data }">
<span class="admin-desktop-config__value" :title="data.value">
{{ truncateValue(data.value) }}
</span>
</template>
</Column>
<Column field="mark" header="备注">
<template #body="{ data }">
{{ data.mark || "—" }}
</template>
</Column>
<Column field="updated_at" header="更新时间">
<template #body="{ data }">
{{ formatDateTime(data.updated_at) }}
</template>
</Column>
<Column header="操作" style="width: 9rem">
<template #body="{ data }">
<div class="admin-users__actions">
<Button
label="编辑"
size="small"
severity="secondary"
text
@click="openEditDialog(data)"
/>
<Button
label="删除"
size="small"
severity="danger"
text
@click="removeConfig(data)"
/>
</div>
</template>
</Column>
<template #empty>
<p class="py-6 text-center text-sm text-text-muted">暂无配置</p>
</template>
</DataTable>
<Dialog
v-model:visible="dialogVisible"
:header="dialogTitle"
modal
:style="{ width: '32rem' }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">键名 (name)</label>
<InputText
v-model="form.name"
class="w-full font-mono uppercase"
placeholder="如 LLM_API_KEY"
:disabled="dialogMode === 'edit'"
/>
<p v-if="dialogMode === 'edit'" class="text-xs text-text-muted">
键名创建后不可修改避免环境变量映射错乱
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase"> (value)</label>
<Textarea
v-model="form.value"
rows="6"
class="w-full font-mono text-sm"
placeholder="支持长文本或 JSON如 OSS_CONFIG"
/>
<p v-if="valuePreview && form.value.length > 120" class="text-xs text-text-muted">
预览{{ valuePreview }}
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">备注 (mark)</label>
<InputText v-model="form.mark" class="w-full" placeholder="可选说明" />
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
<Button label="保存" :loading="saving" @click="saveConfig" />
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { useAuthStore } from "../../stores/auth";
const auth = useAuthStore();
</script>
<template>
<div class="admin-page">
<h1 class="admin-page__title gradient-text">概览</h1>
<p class="text-sm text-text-muted">
欢迎{{ auth.currentUser?.username }}在此查看系统运行概况
</p>
</div>
</template>

View File

@@ -0,0 +1,364 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
import {
listAdminUsersApi,
createAdminUserApi,
updateAdminUserApi,
deleteAdminUserApi,
} from "../../api/adminUsers.js";
const auth = useAuthStore();
const ROLE_OPTIONS = [
{ label: "普通用户", value: 0 },
{ label: "管理员", value: 1 },
{ label: "代理", value: 2 },
];
const ROLE_LABELS = {
0: "普通用户",
[ROLE_ADMIN]: "管理员",
[ROLE_AGENT]: "代理",
};
const users = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const saving = ref(false);
const feedback = ref({ severity: "", message: "" });
const dialogVisible = ref(false);
const dialogMode = ref("create");
const editingId = ref(null);
const emptyForm = () => ({
username: "",
password: "",
phone: "",
role_id: 0,
vip_end_time: "",
clear_vip_end_time: false,
});
const form = ref(emptyForm());
const dialogTitle = computed(() =>
dialogMode.value === "create" ? "新建用户" : "编辑用户",
);
function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function toDatetimeLocalValue(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function roleLabel(roleId) {
return ROLE_LABELS[roleId] ?? `角色 ${roleId}`;
}
function roleSeverity(roleId) {
if (roleId === ROLE_ADMIN) return "info";
if (roleId === ROLE_AGENT) return "warn";
return "secondary";
}
function showMessage(severity, message) {
feedback.value = { severity, message };
}
async function loadUsers() {
loading.value = true;
feedback.value = { severity: "", message: "" };
const res = await listAdminUsersApi(auth.token, {
page: page.value,
pageSize: pageSize.value,
});
loading.value = false;
if (!res.ok) {
showMessage("error", res.message || "加载用户列表失败");
return;
}
const data = res.data;
users.value = Array.isArray(data?.items) ? data.items : [];
total.value = typeof data?.total === "number" ? data.total : 0;
if (typeof data?.page === "number") page.value = data.page;
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
}
async function onPage(event) {
page.value = event.page + 1;
pageSize.value = event.rows;
await loadUsers();
}
function openCreateDialog() {
dialogMode.value = "create";
editingId.value = null;
form.value = emptyForm();
dialogVisible.value = true;
}
function openEditDialog(row) {
dialogMode.value = "edit";
editingId.value = row.id;
form.value = {
username: row.username,
password: "",
phone: row.phone || "",
role_id: row.role_id,
vip_end_time: toDatetimeLocalValue(row.vip_end_time),
clear_vip_end_time: false,
};
dialogVisible.value = true;
}
function buildPayload() {
const payload = {
username: form.value.username.trim(),
phone: form.value.phone.trim() || null,
role_id: form.value.role_id,
};
if (form.value.password.trim()) {
payload.password = form.value.password;
}
if (form.value.clear_vip_end_time) {
payload.clear_vip_end_time = true;
} else if (form.value.vip_end_time) {
payload.vip_end_time = new Date(form.value.vip_end_time).toISOString();
}
return payload;
}
async function saveUser() {
if (!form.value.username.trim()) {
showMessage("warn", "用户名不能为空");
return;
}
if (dialogMode.value === "create" && form.value.password.length < 6) {
showMessage("warn", "密码至少 6 位");
return;
}
saving.value = true;
feedback.value = { severity: "", message: "" };
const payload = buildPayload();
let res;
if (dialogMode.value === "create") {
if (!payload.password) {
saving.value = false;
showMessage("warn", "请设置初始密码");
return;
}
res = await createAdminUserApi(auth.token, payload);
} else {
res = await updateAdminUserApi(auth.token, editingId.value, payload);
}
saving.value = false;
if (!res.ok) {
showMessage("error", res.message || "保存失败");
return;
}
dialogVisible.value = false;
showMessage("success", res.message || "保存成功");
await loadUsers();
}
async function removeUser(row) {
if (row.id === auth.currentUser?.id) {
showMessage("warn", "不能删除当前登录账号");
return;
}
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
if (!ok) return;
const res = await deleteAdminUserApi(auth.token, row.id);
if (!res.ok) {
showMessage("error", res.message || "删除失败");
return;
}
showMessage("success", res.message || "已删除");
if (users.value.length === 1 && page.value > 1) {
page.value -= 1;
}
await loadUsers();
}
onMounted(() => {
loadUsers();
});
</script>
<template>
<div class="admin-page admin-users">
<div class="admin-users__header">
<div>
<h1 class="admin-page__title gradient-text">用户管理</h1>
<p class="text-sm text-text-muted">创建编辑与删除系统用户分配角色与 VIP 到期时间</p>
</div>
<Button label="新建用户" @click="openCreateDialog" />
</div>
<Message
v-if="feedback.message"
:severity="feedback.severity"
:closable="true"
class="admin-users__message"
@close="feedback.message = ''"
>
{{ feedback.message }}
</Message>
<DataTable
:value="users"
:loading="loading"
lazy
paginator
:rows="pageSize"
:total-records="total"
:first="(page - 1) * pageSize"
:rows-per-page-options="[10, 20, 50]"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
current-page-report-template=" {totalRecords} "
striped-rows
size="small"
class="admin-users__table"
data-key="id"
@page="onPage"
>
<Column field="id" header="ID" style="width: 4rem" />
<Column field="username" header="用户名" />
<Column field="phone" header="手机号">
<template #body="{ data }">
{{ data.phone || "—" }}
</template>
</Column>
<Column field="role_id" header="角色" style="width: 7rem">
<template #body="{ data }">
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
</template>
</Column>
<Column field="vip_end_time" header="VIP 到期">
<template #body="{ data }">
{{ formatDateTime(data.vip_end_time) }}
</template>
</Column>
<Column field="created_at" header="注册时间">
<template #body="{ data }">
{{ formatDateTime(data.created_at) }}
</template>
</Column>
<Column header="操作" style="width: 9rem">
<template #body="{ data }">
<div class="admin-users__actions">
<Button
label="编辑"
size="small"
severity="secondary"
text
@click="openEditDialog(data)"
/>
<Button
label="删除"
size="small"
severity="danger"
text
:disabled="data.id === auth.currentUser?.id"
@click="removeUser(data)"
/>
</div>
</template>
</Column>
<template #empty>
<p class="py-6 text-center text-sm text-text-muted">暂无用户</p>
</template>
</DataTable>
<Dialog
v-model:visible="dialogVisible"
:header="dialogTitle"
modal
class="admin-users-dialog"
:style="{ width: '28rem' }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">用户名</label>
<InputText v-model="form.username" class="w-full" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">
{{ dialogMode === "create" ? "密码" : "新密码(留空不修改)" }}
</label>
<Password
v-model="form.password"
:feedback="false"
toggle-mask
fluid
input-class="w-full"
:placeholder="dialogMode === 'create' ? '至少 6 位' : '留空则不修改'"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">手机号可选</label>
<InputText v-model="form.phone" class="w-full" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">角色</label>
<Select
v-model="form.role_id"
:options="ROLE_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">VIP 到期时间</label>
<InputText
v-model="form.vip_end_time"
type="datetime-local"
class="w-full"
:disabled="form.clear_vip_end_time"
/>
<label v-if="dialogMode === 'edit'" class="flex items-center gap-2 text-sm text-text-muted">
<Checkbox v-model="form.clear_vip_end_time" :binary="true" input-id="clear-vip" />
<span>清除 VIP 到期时间</span>
</label>
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
<Button label="保存" :loading="saving" @click="saveUser" />
</template>
</Dialog>
</div>
</template>