chore: 整合部署文件与管理端源码入仓
- deploy/server.Dockerfile、deploy/client-h5.Dockerfile(自 ai-art 仓库迁出,去重) - docker-compose.yml 迁入并修正路径(admin 上下文 ./admin),新增可选 h5 服务(profile 隔离) - admin/:管理前端完整源码(Vite+Vue3,自 workspace/dimension-admin 合并)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
+4
-1
@@ -23,4 +23,7 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swoclient/src/.share-done
|
*.swo
|
||||||
|
client/src/.share-done
|
||||||
|
admin/node_modules/
|
||||||
|
admin/dist/
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
RUN echo 'server{listen 80;root /usr/share/nginx/html;location /api/{proxy_pass http://dimension-server:3001;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;}}' > /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>AI维度 · 管理后台</title></head>
|
||||||
|
<body><div id="app"></div><script type="module" src="/src/main.js"></script></body>
|
||||||
|
</html>
|
||||||
Generated
+1428
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "dimension-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"element-plus": "^2.14.5",
|
||||||
|
"vue": "^3.5.42"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="!token" class="login-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="mark">✦ AI维度 · 管理后台</div>
|
||||||
|
<el-input v-model="username" placeholder="管理员用户名" @keyup.enter="login" />
|
||||||
|
<el-input v-model="password" type="password" placeholder="密码" show-password @keyup.enter="login" />
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||||
|
<el-button type="primary" :loading="busy" style="width:100%" @click="login">登 录</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="layout">
|
||||||
|
<aside class="side">
|
||||||
|
<div class="side-title">✦ AI维度</div>
|
||||||
|
<div v-for="m in menus" :key="m.key" class="menu-item" :class="{ active: view === m.key }" @click="view = m.key">
|
||||||
|
<span>{{ m.icon }}</span>{{ m.label }}
|
||||||
|
</div>
|
||||||
|
<div class="side-foot"><el-button size="small" @click="logout">退出</el-button></div>
|
||||||
|
</aside>
|
||||||
|
<main class="content"><component :is="menus.find(m => m.key === view).comp" /></main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { getToken, setToken, clearToken, apiPost } from './api'
|
||||||
|
import Dashboard from './views/Dashboard.vue'
|
||||||
|
import Users from './views/Users.vue'
|
||||||
|
import Trends from './views/Trends.vue'
|
||||||
|
import Quotas from './views/Quotas.vue'
|
||||||
|
import Feedbacks from './views/Feedbacks.vue'
|
||||||
|
|
||||||
|
const token = ref(getToken())
|
||||||
|
const view = ref('dashboard')
|
||||||
|
const username = ref(''), password = ref(''), busy = ref(false), error = ref('')
|
||||||
|
|
||||||
|
const menus = [
|
||||||
|
{ key: 'dashboard', label: '数据看板', icon: '📊', comp: Dashboard },
|
||||||
|
{ key: 'users', label: '用户管理', icon: '👥', comp: Users },
|
||||||
|
{ key: 'trends', label: '趋势管理', icon: '📈', comp: Trends },
|
||||||
|
{ key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas },
|
||||||
|
{ key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
busy.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const r = await apiPost('/api/auth/admin/login', { username: username.value, password: password.value })
|
||||||
|
setToken(r.data?.token || r.token); token.value = r.data?.token || r.token
|
||||||
|
} catch (e) { error.value = e.message }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
function logout() { clearToken(); token.value = '' }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: -apple-system, 'PingFang SC', sans-serif; background: #f5f6fa; }
|
||||||
|
.login-wrap { min-height: 100vh; display: grid; place-items: center; }
|
||||||
|
.login-card { width: 340px; background: #fff; border-radius: 16px; padding: 32px 28px; display: flex; flex-direction: column; gap: 14px; box-shadow: 0 8px 30px rgba(0,0,0,.1); }
|
||||||
|
.mark { text-align: center; font-size: 18px; font-weight: 700; color: #1f2333; margin-bottom: 6px; }
|
||||||
|
.hint { margin: 0; font-size: 11px; color: #b6bac9; text-align: center; }
|
||||||
|
.layout { display: grid; grid-template-columns: 200px 1fr; min-height: 100vh; }
|
||||||
|
.side { background: #1f2333; color: #cfd3e4; display: flex; flex-direction: column; padding: 16px 10px; position: sticky; top: 0; height: 100vh; }
|
||||||
|
.side-title { font-weight: 700; color: #fff; padding: 6px 12px 18px; font-size: 16px; }
|
||||||
|
.menu-item { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; cursor: pointer; font-size: 14px; margin-bottom: 2px; }
|
||||||
|
.menu-item:hover { background: rgba(255,255,255,.07); }
|
||||||
|
.menu-item.active { background: #6366f1; color: #fff; }
|
||||||
|
.side-foot { margin-top: auto; padding: 10px; }
|
||||||
|
.content { padding: 20px 24px; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout { grid-template-columns: 1fr !important; grid-template-rows: auto 1fr; }
|
||||||
|
.side {
|
||||||
|
position: sticky !important; top: 0; z-index: 30;
|
||||||
|
height: 52px !important; max-height: 52px !important;
|
||||||
|
flex-direction: row !important; align-items: center;
|
||||||
|
overflow-x: auto; overflow-y: hidden;
|
||||||
|
padding: 0 10px; gap: 6px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.side-title {
|
||||||
|
font-size: 14px; padding: 0 8px;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
height: 36px; padding: 0 14px;
|
||||||
|
font-size: 13px; margin-bottom: 0;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.side-foot {
|
||||||
|
margin: 0 0 0 auto; padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.content { padding: 12px; min-width: 0; overflow-x: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const TOKEN_KEY = 'dim_admin_token'
|
||||||
|
export function getToken() { return localStorage.getItem(TOKEN_KEY) || '' }
|
||||||
|
export function setToken(t) { localStorage.setItem(TOKEN_KEY, t) }
|
||||||
|
export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
|
||||||
|
|
||||||
|
export function apiGet(path) {
|
||||||
|
return fetch(path, { headers: { Authorization: `Bearer ${getToken()}` } })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(j => j.data !== undefined ? j.data : j)
|
||||||
|
.catch(e => { throw new Error(e.message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPost(path, body) {
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(r => r.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPut(path, body) {
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(r => r.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiDelete(path) {
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` }
|
||||||
|
}).then(r => r.json())
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import App from './App.vue'
|
||||||
|
createApp(App).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2>数据看板</h2>
|
||||||
|
<div class="cards">
|
||||||
|
<el-card><div class="num">{{ s.newUsers ?? '-' }}</div><div class="lbl">今日新增</div></el-card>
|
||||||
|
<el-card><div class="num">{{ s.activeUsers ?? '-' }}</div><div class="lbl">今日活跃</div></el-card>
|
||||||
|
<el-card><div class="num">{{ s.newOrders ?? '-' }}</div><div class="lbl">今日订单</div></el-card>
|
||||||
|
<el-card><div class="num ok">{{ s.aiChatToday ?? '-' }}</div><div class="lbl">问答总数</div></el-card>
|
||||||
|
<el-card><div class="num">{{ s.aiChatUsers ?? '-' }}</div><div class="lbl">问答用户</div></el-card>
|
||||||
|
<el-card><div class="num">{{ s.aiChatShares ?? '-' }}</div><div class="lbl">分享次数</div></el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { apiGet } from '../api'
|
||||||
|
const s = ref({})
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const d = await apiGet('/api/admin/dashboard')
|
||||||
|
const stats = await apiGet('/api/admin/ai-chat/stats').catch(() => ({}))
|
||||||
|
s.value = {
|
||||||
|
newUsers: d?.today?.newUsers ?? '-',
|
||||||
|
activeUsers: d?.today?.activeUsers ?? '-',
|
||||||
|
newOrders: d?.today?.newOrders ?? '-',
|
||||||
|
aiChatToday: stats?.totalQuestions ?? '-',
|
||||||
|
aiChatUsers: stats?.totalUsers ?? '-',
|
||||||
|
aiChatShares: stats?.totalShares ?? '-',
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; }
|
||||||
|
.num { font-size:26px; font-weight:700; color:#1f2333; }
|
||||||
|
.num.ok { color:#18a058; }
|
||||||
|
.lbl { font-size:12px; color:#8a8fa3; margin-top:4px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="head"><h2>反馈管理</h2></div>
|
||||||
|
<el-table :data="list">
|
||||||
|
<el-table-column prop="content" label="内容" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="user" label="用户" width="120" />
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{row}"><el-tag :type="row.status==='resolved'?'success':'info'" size="small">{{ row.status==='resolved'?'已处理':'待处理' }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="160"><template #default="{row}">{{ row.createdAt?.slice(0,10) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="100">
|
||||||
|
<template #default="{row}"><el-button size="small" type="danger" plain @click="del(row)">删除</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { apiGet } from '../api'
|
||||||
|
const list = ref([])
|
||||||
|
async function load() { const r = await apiGet('/api/admin/feedback'); list.value = r.feedbacks || r.data || [] }
|
||||||
|
async function del(row) { await ElMessageBox.confirm('确认删除?'); await apiDelete(`/api/admin/feedback/${row._id}`); load() }
|
||||||
|
async function apiDelete(url) { await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` } }) }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="head"><h2>问答额度管理</h2>
|
||||||
|
<el-button type="primary" @click="addQuota">新增额度</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list">
|
||||||
|
<el-table-column prop="user" label="用户" width="160" />
|
||||||
|
<el-table-column prop="total" label="总额度" width="90" />
|
||||||
|
<el-table-column prop="used" label="已用" width="80" />
|
||||||
|
<el-table-column prop="remaining" label="剩余" width="80" />
|
||||||
|
<el-table-column prop="date" label="日期" width="120" />
|
||||||
|
</el-table>
|
||||||
|
<el-dialog v-model="dialog" title="增加额度" width="400">
|
||||||
|
<el-form label-width="80">
|
||||||
|
<el-form-item label="用户ID"><el-input v-model="form.userId" /></el-form-item>
|
||||||
|
<el-form-item label="增加次数"><el-input-number v-model="form.amount" :min="1" :max="100" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" @click="save">确认</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, reactive } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { apiGet, apiPost } from '../api'
|
||||||
|
const list = ref([]); const dialog = ref(false)
|
||||||
|
const form = reactive({ userId: '', amount: 5 })
|
||||||
|
async function load() { const r = await apiGet('/api/admin/ai-chat/quota-list'); list.value = r.list || r.data || [] }
|
||||||
|
function addQuota() { dialog.value = true }
|
||||||
|
async function save() {
|
||||||
|
try { await apiPost('/api/admin/ai-chat/add-quota', { userId: form.userId, amount: form.amount }); ElMessage.success('已增加'); dialog.value = false; load() }
|
||||||
|
catch (e) { ElMessage.error(e.message) }
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<style scoped>.head{display:flex;justify-content:space-between;align-items:center}</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="head"><h2>趋势管理</h2><el-button type="primary" @click="dialog=true">新增趋势</el-button></div>
|
||||||
|
<el-table :data="list">
|
||||||
|
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="source" label="来源" width="100" />
|
||||||
|
<el-table-column prop="category" label="分类" width="80" />
|
||||||
|
<el-table-column prop="hot" label="热门" width="70"><template #default="{row}"><el-tag :type="row.hot?'danger':'info'" size="small">{{row.hot?'热':'普通'}}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="160"><template #default="{row}">{{ row.createdAt?.slice(0,10) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="100"><template #default="{row}"><el-button size="small" type="danger" plain @click="del(row)">删除</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-dialog v-model="dialog" title="新增趋势" width="480">
|
||||||
|
<el-form label-width="70">
|
||||||
|
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||||
|
<el-form-item label="来源"><el-input v-model="form.source" placeholder="来源名称" /></el-form-item>
|
||||||
|
<el-form-item label="分类"><el-select v-model="form.category"><el-option v-for="c in ['产品','论文','工具','公司','政策','其他']" :key="c" :value="c" :label="c" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="3" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
|
||||||
|
const list = ref([]); const dialog = ref(false)
|
||||||
|
const form = reactive({ title: '', source: '', category: '其他', summary: '' })
|
||||||
|
async function load() { /* TODO */ }
|
||||||
|
async function save() {
|
||||||
|
try { await apiPost('/api/admin/trends', { ...form }); ElMessage.success('已保存'); dialog.value = false; load() }
|
||||||
|
catch (e) { ElMessage.error(e.message) }
|
||||||
|
}
|
||||||
|
async function del(row) {
|
||||||
|
await ElMessageBox.confirm('确认删除?'); await apiDelete(`/api/admin/trends/${row._id}`); ElMessage.success('已删除'); load()
|
||||||
|
}
|
||||||
|
function apiDelete(url) { return fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` } }) }
|
||||||
|
function apiPost(url, body) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` }, body: JSON.stringify(body) }).then(r => r.json()) }
|
||||||
|
function apiGet(path) { return fetch(path, { headers: { Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` } }).then(r => r.json()) }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<style scoped>.head{display:flex;justify-content:space-between;align-items:center}</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="head"><h2>用户管理</h2><el-input v-model="q" placeholder="搜索昵称" style="width:200px" clearable @change="page=1;load()" /></div>
|
||||||
|
<el-table :data="list">
|
||||||
|
<el-table-column prop="nickname" label="昵称" width="140" />
|
||||||
|
<el-table-column label="手机号" width="130"><template #default="{row}">{{ row.phone || '-' }}</template></el-table-column>
|
||||||
|
<el-table-column label="注册时间" width="160"><template #default="{row}">{{ row.createdAt?.slice(0,10) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作"><template #default="{row}"><el-tag size="small">{{ row._id?.slice(-6) }}</el-tag></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-model:current-page="page" layout="prev,pager,next,total" :total="total" :page-size="20" style="margin-top:14px" @current-change="load" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { apiGet } from '../api'
|
||||||
|
const list = ref([]); const total = ref(0); const page = ref(1); const q = ref('')
|
||||||
|
async function load() {
|
||||||
|
const r = await apiGet(`/api/admin/users?q=${q.value}&page=${page.value}`)
|
||||||
|
list.value = r.users || []; total.value = r.total || 0
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<style scoped>.head{display:flex;justify-content:space-between;align-items:center}</style>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/admin/',
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5180,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://127.0.0.1:3001', changeOrigin: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: { outDir: 'dist' }
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build:h5
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist/build/h5 /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
# 安装 CJK 字体 + 系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
fonts-wqy-microhei ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production PORT=3001 TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# dimension server 是 CJS(require),直接复制源码和依赖
|
||||||
|
COPY server/package.json server/package-lock.json* ./server/ 2>/dev/null || true
|
||||||
|
COPY server/package.json ./
|
||||||
|
RUN cd server 2>/dev/null; npm install --omit=dev --no-audit --no-fund 2>/dev/null || true
|
||||||
|
|
||||||
|
COPY server/src ./server/src
|
||||||
|
COPY server/.env* ./
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "server/src/index.js"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
environment:
|
||||||
|
- MONGO_INITDB_ROOT_USERNAME=${MONGO_USER:-wdkj}
|
||||||
|
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS:-changeMe2026}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:27017:27017"
|
||||||
|
|
||||||
|
dimension-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: deploy/server.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3001
|
||||||
|
- MONGODB_URI=mongodb://${MONGO_USER:-wdkj}:${MONGO_PASS:-changeMe2026}@mongodb:27017/wdkj?authSource=admin
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-changeMe2026}
|
||||||
|
- SITE_URL=${SITE_URL:-}
|
||||||
|
- INVITE_REWARD_CREDITS=${INVITE_REWARD_CREDITS:-100}
|
||||||
|
- COST_PER_WORK=${COST_PER_WORK:-10}
|
||||||
|
- FREE_DAILY_QUOTA=${FREE_DAILY_QUOTA:-20}
|
||||||
|
- MEMBER_DAILY_QUOTA=${MEMBER_DAILY_QUOTA:-60}
|
||||||
|
depends_on:
|
||||||
|
- mongodb
|
||||||
|
volumes:
|
||||||
|
- dim_uploads:/app/uploads
|
||||||
|
|
||||||
|
dimension-admin:
|
||||||
|
build:
|
||||||
|
context: ./admin
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3010:80"
|
||||||
|
|
||||||
|
# 可选:客户端 H5(docker compose --profile h5 up 启用)
|
||||||
|
dimension-h5:
|
||||||
|
build:
|
||||||
|
context: ./client
|
||||||
|
dockerfile: ../deploy/client-h5.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["h5"]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3020:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo_data:
|
||||||
|
dim_uploads:
|
||||||
|
|
||||||
Reference in New Issue
Block a user