diff --git a/apps/ops-web/index.html b/apps/ops-web/index.html new file mode 100644 index 0000000..8a77591 --- /dev/null +++ b/apps/ops-web/index.html @@ -0,0 +1,12 @@ + + + + + + 省心推 · 运营后台 + + +
+ + + diff --git a/apps/ops-web/nginx.conf b/apps/ops-web/nginx.conf new file mode 100644 index 0000000..9016e1d --- /dev/null +++ b/apps/ops-web/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://ops-api:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/apps/ops-web/package.json b/apps/ops-web/package.json new file mode 100644 index 0000000..870486e --- /dev/null +++ b/apps/ops-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "ops-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@tanstack/vue-query": "^5.96.0", + "@vueuse/core": "^14.2.1", + "ant-design-vue": "^4.2.6", + "axios": "^1.7.9", + "dayjs": "^1.11.20", + "pinia": "^3.0.4", + "vue": "^3.5.31", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.5", + "typescript": "^5.9.3", + "vite": "^7.3.2", + "vue-tsc": "^3.2.6" + } +} diff --git a/apps/ops-web/src/App.vue b/apps/ops-web/src/App.vue new file mode 100644 index 0000000..97bc225 --- /dev/null +++ b/apps/ops-web/src/App.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/ops-web/src/env.d.ts b/apps/ops-web/src/env.d.ts new file mode 100644 index 0000000..920f14e --- /dev/null +++ b/apps/ops-web/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/apps/ops-web/src/layouts/AppShell.vue b/apps/ops-web/src/layouts/AppShell.vue new file mode 100644 index 0000000..091e8c0 --- /dev/null +++ b/apps/ops-web/src/layouts/AppShell.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/apps/ops-web/src/lib/http.ts b/apps/ops-web/src/lib/http.ts new file mode 100644 index 0000000..4f5a96d --- /dev/null +++ b/apps/ops-web/src/lib/http.ts @@ -0,0 +1,97 @@ +import axios, { type AxiosError, type AxiosInstance } from "axios"; + +import { clearStoredSession, readStoredSession } from "@/lib/storage"; + +export class OpsApiError extends Error { + status?: number; + code: number; + detail?: string; + requestId?: string; + + constructor(input: { + message: string; + code?: number; + status?: number; + detail?: string; + requestId?: string; + }) { + super(input.message); + this.name = "OpsApiError"; + this.code = input.code ?? 50000; + this.status = input.status; + this.detail = input.detail; + this.requestId = input.requestId; + } +} + +interface ApiEnvelope { + code: number; + message: string; + detail?: string; + data: T; + request_id?: string; +} + +let onUnauthorized: (() => void) | null = null; + +export function bindUnauthorizedHandler(handler: () => void): void { + onUnauthorized = handler; +} + +const client: AxiosInstance = axios.create({ + baseURL: "/api/ops", + timeout: 30000, +}); + +client.interceptors.request.use((config) => { + const session = readStoredSession(); + if (session.accessToken) { + config.headers = config.headers ?? {}; + config.headers["Authorization"] = `Bearer ${session.accessToken}`; + } + return config; +}); + +client.interceptors.response.use( + (response) => response, + (error: AxiosError>) => { + const status = error.response?.status; + const payload = error.response?.data; + + if (status === 401) { + clearStoredSession(); + onUnauthorized?.(); + } + + return Promise.reject( + new OpsApiError({ + message: payload?.message ?? error.message ?? "request_failed", + code: typeof payload?.code === "number" ? payload.code : undefined, + status, + detail: typeof payload?.detail === "string" ? payload.detail : undefined, + requestId: typeof payload?.request_id === "string" ? payload.request_id : undefined, + }), + ); + }, +); + +async function unwrap(promise: Promise<{ data: ApiEnvelope }>): Promise { + const { data } = await promise; + if (data.code !== 0) { + throw new OpsApiError({ + message: data.message, + code: data.code, + detail: data.detail, + requestId: data.request_id, + }); + } + return data.data; +} + +export const http = { + get: (url: string, params?: Record) => + unwrap(client.get(url, { params })), + post: (url: string, body?: B) => unwrap(client.post(url, body)), + patch: (url: string, body?: B) => unwrap(client.patch(url, body)), + delete: (url: string) => unwrap(client.delete(url)), +}; diff --git a/apps/ops-web/src/lib/storage.ts b/apps/ops-web/src/lib/storage.ts new file mode 100644 index 0000000..6b6f4b8 --- /dev/null +++ b/apps/ops-web/src/lib/storage.ts @@ -0,0 +1,61 @@ +const STORAGE_KEY = "geo.ops-web.session"; + +export interface OperatorView { + id: number; + username: string; + display_name: string; + email: string | null; + role: string; + status: string; +} + +export interface OpsSession { + accessToken: string | null; + expiresAt: number | null; + operator: OperatorView | null; +} + +function emptySession(): OpsSession { + return { accessToken: null, expiresAt: null, operator: null }; +} + +function hasStorage(): boolean { + return typeof window !== "undefined" && "localStorage" in window; +} + +export function readStoredSession(): OpsSession { + if (!hasStorage()) return emptySession(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return emptySession(); + const parsed = JSON.parse(raw) as Partial; + return { + accessToken: typeof parsed.accessToken === "string" ? parsed.accessToken : null, + expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null, + operator: + parsed.operator && typeof parsed.operator === "object" + ? (parsed.operator as OperatorView) + : null, + }; + } catch { + return emptySession(); + } +} + +export function writeStoredSession(session: OpsSession): void { + if (!hasStorage()) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + } catch { + // ignore quota / serialization errors + } +} + +export function clearStoredSession(): void { + if (!hasStorage()) return; + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // noop + } +} diff --git a/apps/ops-web/src/main.ts b/apps/ops-web/src/main.ts new file mode 100644 index 0000000..af2f080 --- /dev/null +++ b/apps/ops-web/src/main.ts @@ -0,0 +1,86 @@ +import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query"; +import { + App as AntApp, + Avatar, + Badge, + Button, + Card, + ConfigProvider, + DatePicker, + Descriptions, + Drawer, + Dropdown, + Empty, + Form, + Input, + Layout, + Menu, + Modal, + Pagination, + Popconfirm, + Result, + Select, + Space, + Spin, + Table, + Tag, + Tooltip, + message, +} from "ant-design-vue"; +import { createApp } from "vue"; + +import App from "./App.vue"; +import { bindUnauthorizedHandler } from "@/lib/http"; +import { router } from "@/router"; +import { pinia } from "@/stores/pinia"; +import "./styles.css"; +import "ant-design-vue/dist/reset.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30000, + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +const app = createApp(App); + +bindUnauthorizedHandler(() => { + void router.replace({ name: "login" }); + message.warning("登录已过期,请重新登录"); +}); + +[ + AntApp, + Avatar, + Badge, + Button, + Card, + ConfigProvider, + DatePicker, + Descriptions, + Drawer, + Dropdown, + Empty, + Form, + Input, + Layout, + Menu, + Modal, + Pagination, + Popconfirm, + Result, + Select, + Space, + Spin, + Table, + Tag, + Tooltip, +].forEach((component) => { + app.use(component); +}); + +app.use(pinia).use(router).use(VueQueryPlugin, { queryClient }).mount("#app"); diff --git a/apps/ops-web/src/router/index.ts b/apps/ops-web/src/router/index.ts new file mode 100644 index 0000000..f1464b2 --- /dev/null +++ b/apps/ops-web/src/router/index.ts @@ -0,0 +1,73 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import { pinia } from "@/stores/pinia"; +import { useAuthStore } from "@/stores/auth"; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: () => import("@/views/LoginView.vue"), + meta: { title: "登录" }, + }, + { + path: "/", + component: () => import("@/layouts/AppShell.vue"), + meta: { requiresAuth: true }, + children: [ + { path: "", redirect: "/accounts" }, + { + path: "accounts", + name: "accounts", + component: () => import("@/views/AccountsView.vue"), + meta: { title: "账号管理" }, + }, + { + path: "audits", + name: "audits", + component: () => import("@/views/AuditLogsView.vue"), + meta: { title: "审计日志" }, + }, + { + path: "profile", + name: "profile", + component: () => import("@/views/ProfileView.vue"), + meta: { title: "个人资料" }, + }, + ], + }, + { + path: "/:pathMatch(.*)*", + redirect: "/", + }, + ], +}); + +router.beforeEach((to) => { + const auth = useAuthStore(pinia); + if (!auth.initialized) { + auth.hydrate(); + } + + if (to.meta.requiresAuth && !auth.isAuthenticated) { + return { + name: "login", + query: to.fullPath !== "/" ? { redirect: to.fullPath } : undefined, + }; + } + + if (to.name === "login" && auth.isAuthenticated) { + return { name: "accounts" }; + } + + return true; +}); + +router.afterEach((to) => { + if (typeof document !== "undefined") { + const t = (to.meta.title as string | undefined) ?? "运营后台"; + document.title = `${t} · 省心推`; + } +}); diff --git a/apps/ops-web/src/stores/auth.ts b/apps/ops-web/src/stores/auth.ts new file mode 100644 index 0000000..a2d8752 --- /dev/null +++ b/apps/ops-web/src/stores/auth.ts @@ -0,0 +1,86 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +import { http } from "@/lib/http"; +import { + type OperatorView, + clearStoredSession, + readStoredSession, + writeStoredSession, +} from "@/lib/storage"; + +interface LoginResponse { + access_token: string; + expires_at: number; + operator: OperatorView; +} + +export const useAuthStore = defineStore("ops-auth", () => { + const accessToken = ref(null); + const expiresAt = ref(null); + const operator = ref(null); + const initialized = ref(false); + + function hydrate(): void { + const stored = readStoredSession(); + accessToken.value = stored.accessToken; + expiresAt.value = stored.expiresAt; + operator.value = stored.operator; + initialized.value = true; + } + + async function login(username: string, password: string): Promise { + const result = await http.post("/auth/login", { username, password }); + accessToken.value = result.access_token; + expiresAt.value = result.expires_at; + operator.value = result.operator; + writeStoredSession({ + accessToken: result.access_token, + expiresAt: result.expires_at, + operator: result.operator, + }); + } + + async function refreshSelf(): Promise { + if (!accessToken.value) return; + try { + const me = await http.get("/auth/me"); + operator.value = { + id: me.id, + username: me.username, + display_name: me.display_name, + email: me.email, + role: me.role, + status: me.status, + }; + writeStoredSession({ + accessToken: accessToken.value, + expiresAt: expiresAt.value, + operator: operator.value, + }); + } catch { + logout(); + } + } + + function logout(): void { + accessToken.value = null; + expiresAt.value = null; + operator.value = null; + clearStoredSession(); + } + + const isAuthenticated = computed(() => !!accessToken.value); + + return { + accessToken, + expiresAt, + operator, + initialized, + isAuthenticated, + hydrate, + login, + refreshSelf, + logout, + }; +}); diff --git a/apps/ops-web/src/stores/pinia.ts b/apps/ops-web/src/stores/pinia.ts new file mode 100644 index 0000000..cafea68 --- /dev/null +++ b/apps/ops-web/src/stores/pinia.ts @@ -0,0 +1,3 @@ +import { createPinia } from "pinia"; + +export const pinia = createPinia(); diff --git a/apps/ops-web/src/styles.css b/apps/ops-web/src/styles.css new file mode 100644 index 0000000..40dfc3d --- /dev/null +++ b/apps/ops-web/src/styles.css @@ -0,0 +1,52 @@ +html, +body, +#app { + height: 100%; + margin: 0; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + background-color: #f8fafc; /* Lighter, cooler gray */ + color: #334155; +} + +.ops-shell-content { + padding: 24px 32px; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +.ops-page-title { + font-size: 24px; + font-weight: 700; + color: #0f172a; + margin: 0 0 24px; + letter-spacing: -0.5px; +} + +.ops-card { + background: #ffffff; + border-radius: 12px; + box-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.08), 0 1px 2px -1px rgba(15, 23, 42, 0.04); + padding: 24px; + border: 1px solid #f1f5f9; +} + +.ops-toolbar { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; +} + +/* Make Ant Table headers slightly more modern inside card */ +.ops-card .ant-table-thead > tr > th { + background: #f8fafc; + color: #64748b; + font-weight: 600; + border-bottom: 1px solid #e2e8f0; +} diff --git a/apps/ops-web/src/views/AccountsView.vue b/apps/ops-web/src/views/AccountsView.vue new file mode 100644 index 0000000..6a1b99c --- /dev/null +++ b/apps/ops-web/src/views/AccountsView.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/apps/ops-web/src/views/AuditLogsView.vue b/apps/ops-web/src/views/AuditLogsView.vue new file mode 100644 index 0000000..d516566 --- /dev/null +++ b/apps/ops-web/src/views/AuditLogsView.vue @@ -0,0 +1,196 @@ + + + diff --git a/apps/ops-web/src/views/LoginView.vue b/apps/ops-web/src/views/LoginView.vue new file mode 100644 index 0000000..e8140c3 --- /dev/null +++ b/apps/ops-web/src/views/LoginView.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/apps/ops-web/src/views/ProfileView.vue b/apps/ops-web/src/views/ProfileView.vue new file mode 100644 index 0000000..57a017e --- /dev/null +++ b/apps/ops-web/src/views/ProfileView.vue @@ -0,0 +1,81 @@ + + + diff --git a/apps/ops-web/tsconfig.json b/apps/ops-web/tsconfig.json new file mode 100644 index 0000000..a57f99b --- /dev/null +++ b/apps/ops-web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../packages/tsconfig/base.json", + "compilerOptions": { + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue", + "vite.config.ts" + ] +} diff --git a/apps/ops-web/vite.config.ts b/apps/ops-web/vite.config.ts new file mode 100644 index 0000000..f9623e5 --- /dev/null +++ b/apps/ops-web/vite.config.ts @@ -0,0 +1,59 @@ +import { fileURLToPath, URL } from "node:url"; + +import vue from "@vitejs/plugin-vue"; +import { defineConfig, loadEnv } from "vite"; + +const r = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, ".", ""); + const isProd = mode === "production"; + + return { + plugins: [vue()], + resolve: { + alias: { + "@": r("./src"), + }, + }, + server: { + host: "0.0.0.0", + port: 5174, + proxy: { + "/api": { + target: env.VITE_OPS_API_PROXY_TARGET || "http://localhost:8090", + changeOrigin: true, + }, + }, + }, + esbuild: isProd ? { drop: ["console", "debugger"] } : undefined, + build: { + target: "es2022", + sourcemap: "hidden", + cssCodeSplit: true, + reportCompressedSize: false, + rollupOptions: { + output: { + entryFileNames: "assets/js/[name]-[hash].js", + chunkFileNames: "assets/js/[name]-[hash].js", + assetFileNames: "assets/[name]-[hash][extname]", + manualChunks(id) { + if (!id.includes("node_modules")) return undefined; + if (id.includes("/ant-design-vue/") || id.includes("/@ant-design/")) { + return "antd"; + } + if ( + id.includes("/vue/") || + id.includes("/@vue/") || + id.includes("/vue-router/") || + id.includes("/pinia/") + ) { + return "vue-vendor"; + } + return "vendor"; + }, + }, + }, + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62706fb..2cf63eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,52 @@ importers: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3) + apps/ops-web: + dependencies: + '@ant-design/icons-vue': + specifier: ^7.0.1 + version: 7.0.1(vue@3.5.31(typescript@5.9.3)) + '@tanstack/vue-query': + specifier: ^5.96.0 + version: 5.96.0(vue@3.5.31(typescript@5.9.3)) + '@vueuse/core': + specifier: ^14.2.1 + version: 14.2.1(vue@3.5.31(typescript@5.9.3)) + ant-design-vue: + specifier: ^4.2.6 + version: 4.2.6(vue@3.5.31(typescript@5.9.3)) + axios: + specifier: ^1.7.9 + version: 1.14.0 + dayjs: + specifier: ^1.11.20 + version: 1.11.20 + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3)) + vue: + specifier: ^3.5.31 + version: 3.5.31(typescript@5.9.3) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.31(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.12.0 + '@vitejs/plugin-vue': + specifier: ^6.0.5 + version: 6.0.6(vite@7.3.2(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.2 + version: 7.3.2(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0) + vue-tsc: + specifier: ^3.2.6 + version: 3.2.6(typescript@5.9.3) + packages/http-client: dependencies: '@geo/shared-types': @@ -6051,18 +6097,18 @@ snapshots: vite: 7.3.2(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0) vue: 3.5.31(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.5(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1) - vue: 3.5.31(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.6(vite@7.3.2(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.13 vite: 7.3.2(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0) vue: 3.5.31(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.6(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1) + vue: 3.5.31(typescript@5.9.3) + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 @@ -6230,7 +6276,7 @@ snapshots: '@wxt-dev/module-vue@1.0.3(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))(wxt@0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.2))': dependencies: - '@vitejs/plugin-vue': 6.0.5(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.6(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.5)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3)) wxt: 0.20.20(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.2) transitivePeerDependencies: - vite